You can close this window.
"
+ )
+ else:
+ # Ignore requests without a code (e.g., favicon requests)
+ logger.debug(f"Ignoring request without auth code: {self.path}")
+ self.send_response(404)
+ self.end_headers()
+
+ try:
+ # Start the HTTP server
+ httpd = HTTPServer(("localhost", 8081), OAuthCallbackHandler)
+ server_thread = threading.Thread(target=httpd.serve_forever)
+ server_thread.daemon = True
+ server_thread.start()
+ logger.info("OAuth callback server started on http://localhost:8081")
+
+ # Yield the auth states dict and server URL
+ yield auth_states, "http://localhost:8081"
+
+ finally:
+ # Clean up the server
+ if httpd:
+ logger.info("Shutting down OAuth callback server...")
+ shutdown_thread = threading.Thread(target=httpd.shutdown)
+ shutdown_thread.start()
+ shutdown_thread.join(timeout=2) # Wait up to 2 seconds for shutdown
+ httpd.server_close()
+ logger.info("OAuth callback server shut down successfully")
+ if server_thread:
+ server_thread.join(timeout=1)
+
+
+@pytest.fixture(scope="session")
+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.
+
+ This registers a single OAuth client with Nextcloud that matches the MCP server's
+ registration, allowing all test users to authenticate using the same client_id/secret.
+
+ Now uses the real OAuth callback server for reliable token acquisition.
+
+ Returns:
+ Tuple of (client_id, client_secret, callback_url, token_endpoint, authorization_endpoint)
+ """
+ from nextcloud_mcp_server.auth.client_registration import load_or_register_client
+
+ nextcloud_host = os.getenv("NEXTCLOUD_HOST")
+ if not nextcloud_host:
+ pytest.skip("Shared OAuth client requires NEXTCLOUD_HOST")
+
+ # Get callback URL from the real callback server
+ auth_states, callback_url = oauth_callback_server
+
+ logger.info("Setting up shared OAuth client credentials for all test users...")
+ logger.info(f"Using real callback server at: {callback_url}")
+
+ async with httpx.AsyncClient(timeout=30.0) as http_client:
+ # OIDC Discovery
+ discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
+ discovery_response = await http_client.get(discovery_url)
+ discovery_response.raise_for_status()
+ oidc_config = discovery_response.json()
+
+ token_endpoint = oidc_config.get("token_endpoint")
+ registration_endpoint = oidc_config.get("registration_endpoint")
+ authorization_endpoint = oidc_config.get("authorization_endpoint")
+
+ if not all([token_endpoint, registration_endpoint, authorization_endpoint]):
+ raise ValueError("OIDC discovery missing required endpoints")
+
+ # Register or load shared OAuth client (matches MCP server registration)
+ client_info = await load_or_register_client(
+ nextcloud_url=nextcloud_host,
+ registration_endpoint=registration_endpoint,
+ storage_path=".nextcloud_oauth_shared_test_client.json",
+ client_name="Pytest - Shared Test Client",
+ redirect_uris=[callback_url],
+ )
+
+ logger.info(f"Shared OAuth client ready: {client_info.client_id[:16]}...")
+ logger.info("This client will be reused for all test user authentications")
+
+ return (
+ client_info.client_id,
+ client_info.client_secret,
+ callback_url,
+ token_endpoint,
+ authorization_endpoint,
+ )
+
+
+@pytest.fixture(scope="session")
+async def playwright_oauth_token(
+ anyio_backend, browser, shared_oauth_client_credentials, oauth_callback_server
+) -> str:
+ """
+ Fixture to obtain an OAuth access token using Playwright headless browser automation.
+
+ This fully automates the OAuth flow by:
+ 1. Using shared OAuth client credentials (reused across all users)
+ 2. Navigating to authorization URL in headless browser
+ 3. Programmatically filling in login form
+ 4. Handling OAuth consent
+ 5. Waiting for callback server to receive auth code (NEW: using real callback server!)
+ 6. Exchanging code for access token
+
+ Environment variables required:
+ - NEXTCLOUD_HOST: Nextcloud instance URL
+ - NEXTCLOUD_USERNAME: Username for login
+ - NEXTCLOUD_PASSWORD: Password for login
+
+ Playwright Configuration:
+ - Configure browser via pytest CLI args: --browser firefox --headed
+ - Browser fixture provided by pytest-playwright-asyncio
+ - See: https://playwright.dev/python/docs/test-runners
+ """
+ import secrets
+ import time
+ from urllib.parse import quote
+
+ nextcloud_host = os.getenv("NEXTCLOUD_HOST")
+ username = os.getenv("NEXTCLOUD_USERNAME")
+ password = os.getenv("NEXTCLOUD_PASSWORD")
+
+ if not all([nextcloud_host, username, password]):
+ pytest.skip(
+ "Playwright OAuth requires NEXTCLOUD_HOST, NEXTCLOUD_USERNAME, and NEXTCLOUD_PASSWORD"
+ )
+
+ # Get auth_states dict from callback server
+ auth_states, _ = oauth_callback_server
+
+ # Unpack shared client credentials
+ client_id, client_secret, callback_url, token_endpoint, authorization_endpoint = (
+ shared_oauth_client_credentials
+ )
+
+ logger.info(f"Starting Playwright-based OAuth flow for {username}...")
+ logger.info(f"Using shared OAuth client: {client_id[:16]}...")
+ logger.info(f"Using real callback server at: {callback_url}")
+
+ # Generate unique state parameter for this OAuth flow
+ state = secrets.token_urlsafe(32)
+ logger.debug(f"Generated state: {state[:16]}...")
+
+ # Construct authorization URL with state parameter
+ auth_url = (
+ f"{authorization_endpoint}?"
+ f"response_type=code&"
+ f"client_id={client_id}&"
+ f"redirect_uri={quote(callback_url, safe='')}&"
+ f"state={state}&"
+ f"scope=openid%20profile%20email"
+ )
+
+ # Async browser automation using pytest-playwright's browser fixture
+ context = await browser.new_context(ignore_https_errors=True)
+ page = await context.new_page()
+
+ try:
+ # Navigate to authorization URL
+ logger.debug(f"Navigating to: {auth_url}")
+ await page.goto(auth_url, wait_until="networkidle", timeout=60000)
+
+ # Check if we need to login first
+ current_url = page.url
+ logger.debug(f"Current URL after navigation: {current_url}")
+
+ # If we're on a login page, fill in credentials
+ if "/login" in current_url or "/index.php/login" in current_url:
+ logger.info("Login page detected, filling in credentials...")
+
+ # Wait for login form
+ await page.wait_for_selector('input[name="user"]', timeout=10000)
+
+ # Fill in username and password
+ await page.fill('input[name="user"]', username)
+ await page.fill('input[name="password"]', password)
+
+ logger.debug("Credentials filled, submitting login form...")
+
+ # Submit the form
+ await page.click('button[type="submit"]')
+
+ # Wait for navigation after login
+ await page.wait_for_load_state("networkidle", timeout=60000)
+ current_url = page.url
+ logger.info(f"After login, current URL: {current_url}")
+
+ # Now we should be on the OAuth authorization/consent page or already redirected
+ # Check if there's an authorize button to click
+ try:
+ # Look for common authorization button patterns
+ authorize_button = await page.query_selector(
+ 'button:has-text("Authorize"), button:has-text("Allow"), input[type="submit"][value*="uthoriz"]'
+ )
+
+ if authorize_button:
+ logger.info(
+ "Authorization consent page detected, clicking authorize..."
+ )
+ await authorize_button.click()
+ await page.wait_for_load_state("networkidle", timeout=10000)
+ current_url = page.url
+ logger.debug(f"After authorization, current_url: {current_url}")
+ except Exception as e:
+ logger.debug(f"No authorization button found or already authorized: {e}")
+
+ # Wait for callback server to receive the auth code
+ # Browser will be redirected to localhost:8081 which will capture the code
+ logger.info("Waiting for callback server to receive auth code...")
+ timeout_seconds = 30
+ start_time = time.time()
+ while state not in auth_states:
+ if time.time() - start_time > timeout_seconds:
+ # Take a screenshot for debugging
+ screenshot_path = "/tmp/playwright_oauth_error.png"
+ await page.screenshot(path=screenshot_path)
+ logger.error(f"Screenshot saved to {screenshot_path}")
+ raise TimeoutError(
+ f"Timeout waiting for OAuth callback (state={state[:16]}...)"
+ )
+ await asyncio.sleep(0.5)
+
+ auth_code = auth_states[state]
+ logger.info(f"Successfully received authorization code: {auth_code[:20]}...")
+
+ finally:
+ await context.close()
+
+ # Exchange authorization code for access token
+ logger.info("Exchanging authorization code for access token...")
+ async with httpx.AsyncClient(timeout=30.0) as http_client:
+ token_response = await http_client.post(
+ token_endpoint,
+ data={
+ "grant_type": "authorization_code",
+ "code": auth_code,
+ "redirect_uri": callback_url,
+ "client_id": client_id,
+ "client_secret": client_secret,
+ },
+ )
+
+ 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: {token_data}")
+
+ logger.info("Successfully obtained OAuth access token via Playwright")
+ return access_token
+
+
+@pytest.fixture(scope="session")
+async def test_users_setup(anyio_backend, nc_client: NextcloudClient):
+ """
+ Create test users for multi-user OAuth testing.
+
+ Creates four test users:
+ - alice: Owner role, creates resources
+ - bob: Viewer role, read-only access
+ - charlie: Editor role, can edit (in 'editors' group)
+ - diana: No-access role, no shares
+ """
+ test_user_configs = {
+ "alice": {
+ "password": "AliceSecurePass123!",
+ "email": "alice@example.com",
+ "display_name": "Alice Owner",
+ "groups": [],
+ },
+ "bob": {
+ "password": "BobSecurePass456!",
+ "email": "bob@example.com",
+ "display_name": "Bob Viewer",
+ "groups": [],
+ },
+ "charlie": {
+ "password": "CharlieSecurePass789!",
+ "email": "charlie@example.com",
+ "display_name": "Charlie Editor",
+ "groups": ["editors"],
+ },
+ "diana": {
+ "password": "DianaSecurePass012!",
+ "email": "diana@example.com",
+ "display_name": "Diana NoAccess",
+ "groups": [],
+ },
+ }
+
+ logger.info("Creating test users for multi-user OAuth testing...")
+ created_users = []
+
+ try:
+ # Create the 'editors' group first (charlie needs it)
+ try:
+ # Use admin nc_client to create the group via User API
+ # First, try to create it (will fail if exists, but that's okay)
+ async with httpx.AsyncClient() as http_client:
+ base_url = str(nc_client._client.base_url)
+ # Get password from environment since nc_client doesn't expose it
+ password = os.getenv("NEXTCLOUD_PASSWORD")
+ response = await http_client.post(
+ f"{base_url}/ocs/v2.php/cloud/groups",
+ auth=(nc_client.username, password),
+ headers={"OCS-APIRequest": "true", "Accept": "application/json"},
+ data={"groupid": "editors"},
+ )
+ if response.status_code in [
+ 200,
+ 409,
+ ]: # 200 = created, 409 = already exists
+ logger.info("Editors group ready")
+ else:
+ logger.warning(
+ f"Group creation returned {response.status_code}: {response.text}"
+ )
+ except Exception as e:
+ logger.warning(f"Error creating editors group (may already exist): {e}")
+
+ # Create each test user
+ for username, config in test_user_configs.items():
+ try:
+ await nc_client.users.create_user(
+ userid=username,
+ password=config["password"],
+ display_name=config["display_name"],
+ email=config["email"],
+ )
+ logger.info(f"Created test user: {username}")
+ created_users.append(username)
+
+ # Add user to groups if specified
+ for group in config["groups"]:
+ try:
+ await nc_client.users.add_user_to_group(username, group)
+ logger.info(f"Added {username} to group {group}")
+ except Exception as e:
+ logger.warning(f"Error adding {username} to group {group}: {e}")
+
+ except Exception as e:
+ # User might already exist, that's okay
+ logger.warning(
+ f"Could not create user {username} (may already exist): {e}"
+ )
+ created_users.append(username) # Add to list anyway for cleanup
+
+ logger.info(f"Test users setup complete: {created_users}")
+ yield test_user_configs
+
+ finally:
+ # Cleanup: delete test users
+ logger.info("Cleaning up test users...")
+ for username in created_users:
+ try:
+ await nc_client.users.delete_user(username)
+ logger.info(f"Deleted test user: {username}")
+ except Exception as e:
+ logger.warning(f"Error deleting test user {username}: {e}")
+
+
+async def _get_oauth_token_for_user(
+ browser,
+ shared_oauth_client_credentials,
+ auth_states,
+ username: str,
+ password: str,
+) -> str:
+ """
+ Helper function to get OAuth access token for a user via Playwright.
+
+ Uses shared OAuth client credentials to authenticate multiple users with the same client.
+ Now uses real callback server with state parameters for reliable token acquisition.
+
+ Args:
+ browser: Playwright browser instance
+ shared_oauth_client_credentials: Tuple of (client_id, client_secret, callback_url, token_endpoint, authorization_endpoint)
+ auth_states: Dict mapping state parameters to auth codes (from callback server)
+ username: Username to authenticate as
+ password: Password for the user
+
+ Returns:
+ OAuth access token string
+ """
+ import secrets
+ import time
+ from urllib.parse import quote
+
+ nextcloud_host = os.getenv("NEXTCLOUD_HOST")
+
+ if not nextcloud_host:
+ pytest.skip("OAuth requires NEXTCLOUD_HOST")
+
+ # Unpack shared client credentials
+ client_id, client_secret, callback_url, token_endpoint, authorization_endpoint = (
+ shared_oauth_client_credentials
+ )
+
+ logger.info(f"Getting OAuth token for user: {username}...")
+ logger.info(f"Using shared OAuth client: {client_id[:16]}...")
+
+ # Generate unique state parameter for this OAuth flow
+ state = secrets.token_urlsafe(32)
+ logger.debug(f"Generated state for {username}: {state[:16]}...")
+
+ # Construct authorization URL with state parameter
+ auth_url = (
+ f"{authorization_endpoint}?"
+ f"response_type=code&"
+ f"client_id={client_id}&"
+ f"redirect_uri={quote(callback_url, safe='')}&"
+ f"state={state}&"
+ f"scope=openid%20profile%20email"
+ )
+
+ logger.info(f"Performing browser OAuth flow for {username}...")
+ logger.debug(f"Authorization URL: {auth_url}")
+
+ # Browser automation
+ context = await browser.new_context(ignore_https_errors=True)
+ page = await context.new_page()
+
+ try:
+ 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
+
+ # 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(f"Authorizing for {username}...")
+ await authorize_button.click()
+ await page.wait_for_load_state("networkidle", timeout=10000)
+ except Exception as e:
+ logger.debug(f"No authorization needed for {username}: {e}")
+
+ # Wait for callback server to receive the auth code
+ # Browser will be redirected to localhost:8081 which will capture the code
+ logger.info(
+ f"Waiting for callback server to receive auth code for {username}..."
+ )
+ timeout_seconds = 30
+ start_time = time.time()
+ while state not in auth_states:
+ if time.time() - start_time > timeout_seconds:
+ # Take screenshot for debugging
+ screenshot_path = f"/tmp/playwright_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} (state={state[:16]}...)"
+ )
+ await asyncio.sleep(0.5)
+
+ auth_code = auth_states[state]
+ logger.info(f"Got auth code for {username}: {auth_code[:20]}...")
+
+ finally:
+ await context.close()
+
+ # Exchange code for token
+ logger.info(f"Exchanging auth code for access token ({username})...")
+ async with httpx.AsyncClient(timeout=30.0) as http_client:
+ token_response = await http_client.post(
+ token_endpoint,
+ data={
+ "grant_type": "authorization_code",
+ "code": auth_code,
+ "redirect_uri": callback_url,
+ "client_id": client_id,
+ "client_secret": client_secret,
+ },
+ )
+
+ 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 obtained OAuth token for {username}")
+ return access_token
+
+
+# Parallel token retrieval fixture - fetches all OAuth tokens concurrently
+@pytest.fixture(scope="session")
+async def all_oauth_tokens(
+ 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.
+
+ Returns a dict mapping username to OAuth access token.
+ This is significantly faster than fetching tokens sequentially.
+
+ Now uses the real callback server with state parameters for reliable
+ concurrent token acquisition without race conditions.
+ """
+ import asyncio
+ import time
+
+ # Get auth_states dict from callback server
+ auth_states, callback_url = oauth_callback_server
+
+ start_time = time.time()
+ logger.info("Fetching OAuth tokens for all users in parallel...")
+ logger.info(f"Using callback server at {callback_url} with state-based correlation")
+
+ async def get_token_with_delay(username: str, config: dict, delay: float):
+ """Get token for a user after a small delay to stagger requests."""
+ if delay > 0:
+ await asyncio.sleep(delay)
+ return await _get_oauth_token_for_user(
+ browser,
+ shared_oauth_client_credentials,
+ auth_states,
+ username,
+ config["password"],
+ )
+
+ # Create tasks for all users with staggered starts (0.5s apart)
+ tasks = {
+ username: get_token_with_delay(username, config, idx * 0.5)
+ for idx, (username, config) in enumerate(test_users_setup.items())
+ }
+
+ # Run all token fetches concurrently
+ results = await asyncio.gather(*tasks.values(), return_exceptions=True)
+
+ # Build result dict, handling any errors
+ tokens = {}
+ for username, result in zip(tasks.keys(), results):
+ if isinstance(result, Exception):
+ logger.error(f"Failed to get OAuth token for {username}: {result}")
+ raise result
+ tokens[username] = result
+
+ elapsed = time.time() - start_time
+ logger.info(
+ f"Successfully fetched {len(tokens)} OAuth tokens in parallel "
+ f"in {elapsed:.1f}s (~{elapsed / len(tokens):.1f}s per user)"
+ )
+ return tokens
+
+
+# Session-scoped OAuth token fixtures - now use the parallel fixture
+@pytest.fixture(scope="session")
+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(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(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(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)."""
+ async for session in create_mcp_client_session(
+ url="http://127.0.0.1:8001/mcp",
+ token=alice_oauth_token,
+ client_name="Alice MCP",
+ ):
+ yield session
+
+
+@pytest.fixture(scope="session")
+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",
+ ):
+ 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)."""
+ async for session in create_mcp_client_session(
+ url="http://127.0.0.1:8001/mcp",
+ token=charlie_oauth_token,
+ client_name="Charlie MCP",
+ ):
+ yield session
+
+
+@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)."""
+ async for session in create_mcp_client_session(
+ url="http://127.0.0.1:8001/mcp",
+ token=diana_oauth_token,
+ client_name="Diana MCP",
+ ):
+ yield session
+
+
+# Test user/group fixtures for clean test isolation
+@pytest.fixture
+async def test_user(nc_client: NextcloudClient):
+ """
+ Fixture that creates a test user and cleans it up after the test.
+
+ Returns a dict with user details that can be customized.
+ Usage:
+ async def test_something(test_user):
+ user_config = test_user
+ await nc_client.users.create_user(**user_config)
+ """
+ import uuid
+
+ # Generate unique user ID to avoid conflicts
+ userid = f"testuser_{uuid.uuid4().hex[:8]}"
+ password = "SecureTestPassword123!"
+
+ user_config = {
+ "userid": userid,
+ "password": password,
+ "display_name": f"Test User {userid}",
+ "email": f"{userid}@example.com",
+ }
+
+ # Cleanup before (in case of previous failed run)
+ try:
+ await nc_client.users.delete_user(userid)
+ except Exception:
+ pass
+
+ yield user_config
+
+ # Cleanup after test
+ try:
+ await nc_client.users.delete_user(userid)
+ logger.debug(f"Cleaned up test user: {userid}")
+ except Exception as e:
+ logger.warning(f"Failed to cleanup test user {userid}: {e}")
+
+
+@pytest.fixture
+async def test_group(nc_client: NextcloudClient):
+ """
+ Fixture that creates a test group and cleans it up after the test.
+
+ Returns the group ID.
+ """
+ import uuid
+
+ # Generate unique group ID to avoid conflicts
+ groupid = f"testgroup_{uuid.uuid4().hex[:8]}"
+
+ # Cleanup before (in case of previous failed run)
+ try:
+ await nc_client.groups.delete_group(groupid)
+ except Exception:
+ pass
+
+ # Create the group
+ await nc_client.groups.create_group(groupid)
+ logger.debug(f"Created test group: {groupid}")
+
+ yield groupid
+
+ # Cleanup after test
+ try:
+ await nc_client.groups.delete_group(groupid)
+ logger.debug(f"Cleaned up test group: {groupid}")
+ except Exception as e:
+ logger.warning(f"Failed to cleanup test group {groupid}: {e}")
+
+
+@pytest.fixture
+async def test_user_in_group(nc_client: NextcloudClient, test_user, test_group):
+ """
+ Fixture that creates a test user and adds them to a test group.
+
+ Returns a tuple of (user_config, groupid).
+ """
+ user_config = test_user
+ groupid = test_group
+
+ # Create the user
+ await nc_client.users.create_user(**user_config)
+
+ # Add user to group
+ await nc_client.users.add_user_to_group(user_config["userid"], groupid)
+ logger.debug(f"Added user {user_config['userid']} to group {groupid}")
+
+ yield (user_config, groupid)
diff --git a/tests/fixtures/nginx.conf b/tests/fixtures/nginx.conf
new file mode 100644
index 0000000..14298e8
--- /dev/null
+++ b/tests/fixtures/nginx.conf
@@ -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;
+ }
+ }
+}
diff --git a/tests/fixtures/test_recipe.html b/tests/fixtures/test_recipe.html
new file mode 100644
index 0000000..260736a
--- /dev/null
+++ b/tests/fixtures/test_recipe.html
@@ -0,0 +1,133 @@
+
+
+
+
+ Black Pepper Tofu
+ By Yotam Ottolenghi
+
+ A flavorful black pepper tofu dish with aromatic spices and crispy texture.
+ Inspired by Yotam Ottolenghi's signature style.
+
+
+
+
+ Ingredients
+
+ - 400g firm tofu, pressed and cubed
+ - 2 tablespoons black peppercorns, coarsely ground
+ - 3 tablespoons soy sauce
+ - 2 tablespoons rice vinegar
+ - 1 tablespoon maple syrup
+ - 2 tablespoons cornstarch
+ - 3 tablespoons vegetable oil
+ - 4 cloves garlic, minced
+ - 1 tablespoon fresh ginger, grated
+ - 2 spring onions, sliced
+ - 1 red bell pepper, sliced
+ - Sesame seeds for garnish
+
+
+ Instructions
+
+ - Press the tofu for at least 15 minutes to remove excess moisture. Cut into 2cm cubes.
+ - Toss tofu cubes with cornstarch until evenly coated.
+ - Heat 2 tablespoons of oil in a large pan over medium-high heat. Add tofu and cook until golden and crispy on all sides, about 8-10 minutes. Remove and set aside.
+ - In the same pan, add remaining oil. Add garlic, ginger, and ground black pepper. Cook for 1 minute until fragrant.
+ - Add bell pepper and cook for 2-3 minutes until slightly softened.
+ - Mix soy sauce, rice vinegar, and maple syrup. Pour into the pan and bring to a simmer.
+ - Return the crispy tofu to the pan and toss to coat in the sauce. Cook for 2 minutes.
+ - Garnish with spring onions and sesame seeds. Serve immediately with rice or noodles.
+
+
+ Nutrition Information
+ Per serving: 280 calories, 18g protein, 16g fat, 18g carbohydrates, 3g fiber
+
+
+
diff --git a/tests/load/README_OAUTH.md b/tests/load/README_OAUTH.md
new file mode 100644
index 0000000..94a6716
--- /dev/null
+++ b/tests/load/README_OAUTH.md
@@ -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
diff --git a/tests/load/__init__.py b/tests/load/__init__.py
new file mode 100644
index 0000000..0734817
--- /dev/null
+++ b/tests/load/__init__.py
@@ -0,0 +1 @@
+"""Load testing utilities for Nextcloud MCP Server."""
diff --git a/tests/load/benchmark.py b/tests/load/benchmark.py
new file mode 100644
index 0000000..53af736
--- /dev/null
+++ b/tests/load/benchmark.py
@@ -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()
diff --git a/tests/load/cleanup_loadtest_users.py b/tests/load/cleanup_loadtest_users.py
new file mode 100644
index 0000000..1492b23
--- /dev/null
+++ b/tests/load/cleanup_loadtest_users.py
@@ -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()
diff --git a/tests/load/oauth_benchmark.py b/tests/load/oauth_benchmark.py
new file mode 100644
index 0000000..2c20b2b
--- /dev/null
+++ b/tests/load/oauth_benchmark.py
@@ -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"You can close this window.
"
+ )
+ 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()
diff --git a/tests/load/oauth_metrics.py b/tests/load/oauth_metrics.py
new file mode 100644
index 0000000..1312c26
--- /dev/null
+++ b/tests/load/oauth_metrics.py
@@ -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")
diff --git a/tests/load/oauth_pool.py b/tests/load/oauth_pool.py
new file mode 100644
index 0000000..9ed4fea
--- /dev/null
+++ b/tests/load/oauth_pool.py
@@ -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))
diff --git a/tests/load/oauth_workloads.py b/tests/load/oauth_workloads.py
new file mode 100644
index 0000000..bbd4b32
--- /dev/null
+++ b/tests/load/oauth_workloads.py
@@ -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),
+ }
diff --git a/tests/load/workloads.py b/tests/load/workloads.py
new file mode 100644
index 0000000..0fb5a09
--- /dev/null
+++ b/tests/load/workloads.py
@@ -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()
diff --git a/tests/server/test_calendar_todos_mcp.py b/tests/server/test_calendar_todos_mcp.py
new file mode 100644
index 0000000..ff235e6
--- /dev/null
+++ b/tests/server/test_calendar_todos_mcp.py
@@ -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
diff --git a/tests/integration/test_contacts_mcp.py b/tests/server/test_contacts_mcp.py
similarity index 100%
rename from tests/integration/test_contacts_mcp.py
rename to tests/server/test_contacts_mcp.py
diff --git a/tests/server/test_cookbook_mcp.py b/tests/server/test_cookbook_mcp.py
new file mode 100644
index 0000000..e4c90a3
--- /dev/null
+++ b/tests/server/test_cookbook_mcp.py
@@ -0,0 +1,554 @@
+import asyncio
+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
+
+
+async def test_mcp_cookbook_create_and_read_recipe(
+ nc_mcp_client: ClientSession, nc_client: NextcloudClient
+):
+ """Test creating and reading a recipe via MCP tools with verification via NextcloudClient."""
+
+ unique_suffix = uuid.uuid4().hex[:8]
+ recipe_name = f"MCP Test Recipe {unique_suffix}"
+ recipe_data = {
+ "name": recipe_name,
+ "description": "A test recipe created via MCP tools",
+ "recipeIngredient": ["100g flour", "2 eggs", "200ml milk"],
+ "recipeInstructions": ["Mix ingredients", "Cook for 20 minutes", "Serve hot"],
+ "recipeCategory": "MCPTesting",
+ "keywords": f"mcp,testing,{unique_suffix}",
+ "recipeYield": 4,
+ "prepTime": "PT15M",
+ "cookTime": "PT20M",
+ "totalTime": "PT35M",
+ }
+
+ created_recipe_id = None
+
+ try:
+ # 1. Create recipe via MCP
+ logger.info(f"Creating recipe via MCP: {recipe_name}")
+ create_result = await nc_mcp_client.call_tool(
+ "nc_cookbook_create_recipe",
+ {
+ "name": recipe_name,
+ "description": recipe_data["description"],
+ "ingredients": recipe_data["recipeIngredient"],
+ "instructions": recipe_data["recipeInstructions"],
+ "category": recipe_data["recipeCategory"],
+ "keywords": recipe_data["keywords"],
+ "recipe_yield": recipe_data["recipeYield"],
+ "prep_time": recipe_data["prepTime"],
+ "cook_time": recipe_data["cookTime"],
+ "total_time": recipe_data["totalTime"],
+ },
+ )
+
+ assert create_result.isError is False, (
+ f"MCP recipe creation failed: {create_result.content}"
+ )
+
+ create_response = json.loads(create_result.content[0].text)
+ created_recipe_id = create_response["id"]
+ logger.info(f"Recipe created via MCP with ID: {created_recipe_id}")
+
+ # 2. Verify creation via direct NextcloudClient
+ direct_recipe = await nc_client.cookbook.get_recipe(created_recipe_id)
+ assert direct_recipe["name"] == recipe_name
+ assert direct_recipe["description"] == "A test recipe created via MCP tools"
+ assert len(direct_recipe["recipeIngredient"]) == 3
+ assert len(direct_recipe["recipeInstructions"]) == 3
+ assert direct_recipe["recipeCategory"] == "MCPTesting"
+
+ # 3. Read recipe via MCP
+ logger.info(f"Reading recipe via MCP: {created_recipe_id}")
+ read_result = await nc_mcp_client.call_tool(
+ "nc_cookbook_get_recipe", {"recipe_id": created_recipe_id}
+ )
+
+ assert read_result.isError is False, (
+ f"MCP recipe read failed: {read_result.content}"
+ )
+
+ read_recipe = json.loads(read_result.content[0].text)
+ assert read_recipe["name"] == recipe_name
+ assert read_recipe["description"] == "A test recipe created via MCP tools"
+ assert len(read_recipe["recipeIngredient"]) == 3
+
+ logger.info(f"Successfully verified recipe {created_recipe_id} via MCP")
+
+ finally:
+ # Cleanup
+ if created_recipe_id is not None:
+ try:
+ await nc_client.cookbook.delete_recipe(created_recipe_id)
+ logger.info(f"Cleaned up recipe {created_recipe_id}")
+ except Exception as e:
+ logger.warning(f"Failed to cleanup recipe: {e}")
+
+
+async def test_mcp_cookbook_update_recipe(
+ nc_mcp_client: ClientSession, nc_client: NextcloudClient
+):
+ """Test updating a recipe via MCP tools."""
+
+ unique_suffix = uuid.uuid4().hex[:8]
+ recipe_name = f"MCP Update Test {unique_suffix}"
+ recipe_data = {
+ "name": recipe_name,
+ "description": "Original description",
+ "recipeIngredient": ["100g flour"],
+ "recipeInstructions": ["Mix ingredients"],
+ "recipeCategory": "Original",
+ }
+
+ created_recipe_id = None
+
+ try:
+ # 1. Create recipe via direct client
+ logger.info(f"Creating recipe for update test: {recipe_name}")
+ created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
+
+ # 2. Update recipe via MCP (tool handles fetching current recipe internally)
+ logger.info(f"Updating recipe via MCP: {created_recipe_id}")
+ update_result = await nc_mcp_client.call_tool(
+ "nc_cookbook_update_recipe",
+ {
+ "recipe_id": created_recipe_id,
+ "description": "Updated via MCP",
+ "ingredients": ["100g flour", "2 eggs"],
+ "instructions": ["Mix ingredients", "Cook"],
+ "category": "Updated",
+ },
+ )
+
+ assert update_result.isError is False, (
+ f"MCP recipe update failed: {update_result.content}"
+ )
+
+ # 4. Verify update via direct NextcloudClient
+ await asyncio.sleep(1) # Allow propagation
+ updated_recipe = await nc_client.cookbook.get_recipe(created_recipe_id)
+ assert updated_recipe["description"] == "Updated via MCP"
+ assert len(updated_recipe["recipeIngredient"]) == 2
+ assert len(updated_recipe["recipeInstructions"]) == 2
+ assert updated_recipe["recipeCategory"] == "Updated"
+
+ logger.info(f"Successfully updated recipe {created_recipe_id} via MCP")
+
+ finally:
+ # Cleanup
+ if created_recipe_id is not None:
+ try:
+ await nc_client.cookbook.delete_recipe(created_recipe_id)
+ logger.info(f"Cleaned up recipe {created_recipe_id}")
+ except Exception as e:
+ logger.warning(f"Failed to cleanup recipe: {e}")
+
+
+async def test_mcp_cookbook_delete_recipe(
+ nc_mcp_client: ClientSession, nc_client: NextcloudClient
+):
+ """Test deleting a recipe via MCP tools."""
+
+ unique_suffix = uuid.uuid4().hex[:8]
+ recipe_name = f"MCP Delete Test {unique_suffix}"
+ recipe_data = {
+ "name": recipe_name,
+ "description": "Recipe to be deleted",
+ "recipeIngredient": ["test"],
+ "recipeInstructions": ["test"],
+ }
+
+ created_recipe_id = None
+
+ try:
+ # 1. Create recipe via direct client
+ logger.info(f"Creating recipe for delete test: {recipe_name}")
+ created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
+
+ # 2. Delete recipe via MCP
+ logger.info(f"Deleting recipe via MCP: {created_recipe_id}")
+ delete_result = await nc_mcp_client.call_tool(
+ "nc_cookbook_delete_recipe", {"recipe_id": created_recipe_id}
+ )
+
+ assert delete_result.isError is False, (
+ f"MCP recipe deletion failed: {delete_result.content}"
+ )
+
+ # 3. Verify deletion via direct NextcloudClient
+ try:
+ await nc_client.cookbook.get_recipe(created_recipe_id)
+ pytest.fail("Recipe should have been deleted but was still found")
+ except Exception:
+ # Expected - recipe should be deleted
+ logger.info(f"Successfully verified recipe {created_recipe_id} was deleted")
+ created_recipe_id = None # Mark as cleaned up
+
+ finally:
+ # Cleanup in case of test failure
+ if created_recipe_id is not None:
+ try:
+ await nc_client.cookbook.delete_recipe(created_recipe_id)
+ logger.info(f"Cleaned up recipe {created_recipe_id}")
+ except Exception as e:
+ logger.warning(f"Failed to cleanup recipe: {e}")
+
+
+async def test_mcp_cookbook_import_recipe_from_url(
+ nc_mcp_client: ClientSession,
+ nc_client: NextcloudClient,
+):
+ """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 an nginx container to serve reliable, controlled test data.
+ """
+ # 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 nginx container via MCP: {test_url}")
+ import_result = await nc_mcp_client.call_tool(
+ "nc_cookbook_import_recipe", {"url": test_url}
+ )
+
+ assert import_result.isError is False, (
+ f"MCP recipe import failed: {import_result.content}"
+ )
+
+ import_response = json.loads(import_result.content[0].text)
+ created_recipe_id = int(import_response["recipe_id"])
+ imported_recipe = import_response["recipe"]
+
+ logger.info(f"Successfully imported recipe via MCP: {imported_recipe['name']}")
+
+ # 2. Verify basic recipe structure
+ assert imported_recipe["name"] == "Black Pepper Tofu"
+ assert imported_recipe.get("description")
+ assert len(imported_recipe.get("recipeIngredient", [])) > 0
+ assert len(imported_recipe.get("recipeInstructions", [])) > 0
+ assert imported_recipe.get("recipeCategory") == "Main Course"
+ assert "tofu" in imported_recipe.get("keywords", "").lower()
+
+ # 3. Verify we can read it back via direct NextcloudClient
+ retrieved = await nc_client.cookbook.get_recipe(created_recipe_id)
+ assert retrieved["name"] == imported_recipe["name"]
+ logger.info(f"Verified imported recipe ID: {created_recipe_id}")
+
+ finally:
+ # Cleanup
+ if created_recipe_id is not None:
+ try:
+ await nc_client.cookbook.delete_recipe(created_recipe_id)
+ logger.info(f"Cleaned up imported recipe {created_recipe_id}")
+ except Exception as e:
+ logger.warning(f"Failed to cleanup imported recipe: {e}")
+
+
+async def test_mcp_cookbook_search_recipes(
+ nc_mcp_client: ClientSession, nc_client: NextcloudClient
+):
+ """Test searching recipes via MCP tools."""
+
+ unique_keyword = f"mcptestkeyword{uuid.uuid4().hex[:8]}"
+ recipe_name = f"MCP Search Test {uuid.uuid4().hex[:8]}"
+ recipe_data = {
+ "name": recipe_name,
+ "description": f"Recipe for testing MCP search with {unique_keyword}",
+ "keywords": unique_keyword,
+ "recipeIngredient": ["test ingredient"],
+ "recipeInstructions": ["test instruction"],
+ }
+
+ created_recipe_id = None
+
+ try:
+ # 1. Create recipe via direct client
+ logger.info(f"Creating recipe for search test with keyword: {unique_keyword}")
+ created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
+
+ # 2. Allow time for indexing
+ await asyncio.sleep(2)
+
+ # 3. Search for the recipe via MCP
+ logger.info(f"Searching for recipes via MCP with keyword: {unique_keyword}")
+ search_result = await nc_mcp_client.call_tool(
+ "nc_cookbook_search_recipes", {"query": unique_keyword}
+ )
+
+ assert search_result.isError is False, (
+ f"MCP recipe search failed: {search_result.content}"
+ )
+
+ search_response = json.loads(search_result.content[0].text)
+ search_results = search_response["recipes"]
+
+ assert isinstance(search_results, list)
+ assert len(search_results) > 0
+
+ # 4. Verify our recipe is in the results
+ found = any(str(r.get("id")) == str(created_recipe_id) for r in search_results)
+ assert found, f"Recipe {created_recipe_id} not found in search results"
+ logger.info(
+ f"Successfully found recipe {created_recipe_id} in MCP search results"
+ )
+
+ finally:
+ # Cleanup
+ if created_recipe_id is not None:
+ try:
+ await nc_client.cookbook.delete_recipe(created_recipe_id)
+ logger.info(f"Cleaned up recipe {created_recipe_id}")
+ except Exception as e:
+ logger.warning(f"Failed to cleanup recipe: {e}")
+
+
+async def test_mcp_cookbook_list_recipes(
+ nc_mcp_client: ClientSession, nc_client: NextcloudClient
+):
+ """Test listing all recipes via MCP tools."""
+
+ logger.info("Listing all recipes via MCP")
+ list_result = await nc_mcp_client.call_tool("nc_cookbook_list_recipes", {})
+
+ assert list_result.isError is False, (
+ f"MCP list recipes failed: {list_result.content}"
+ )
+
+ list_response = json.loads(list_result.content[0].text)
+ recipes = list_response["recipes"]
+
+ assert isinstance(recipes, list)
+ logger.info(f"Found {len(recipes)} recipes via MCP")
+
+
+async def test_mcp_cookbook_categories_workflow(
+ nc_mcp_client: ClientSession, nc_client: NextcloudClient
+):
+ """Test category listing and filtering via MCP tools."""
+
+ unique_category = f"MCPTestCategory{uuid.uuid4().hex[:8]}"
+ recipe_name = f"MCP Category Test {uuid.uuid4().hex[:8]}"
+ recipe_data = {
+ "name": recipe_name,
+ "recipeCategory": unique_category,
+ "recipeIngredient": ["test"],
+ "recipeInstructions": ["test"],
+ }
+
+ created_recipe_id = None
+
+ try:
+ # 1. Create recipe in test category
+ logger.info(f"Creating recipe in category: {unique_category}")
+ created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
+
+ # 2. Allow time for indexing
+ await asyncio.sleep(2)
+
+ # 3. List categories via MCP
+ logger.info("Listing categories via MCP")
+ categories_result = await nc_mcp_client.call_tool(
+ "nc_cookbook_list_categories", {}
+ )
+
+ assert categories_result.isError is False, (
+ f"MCP list categories failed: {categories_result.content}"
+ )
+
+ categories_response = json.loads(categories_result.content[0].text)
+ categories = categories_response["categories"]
+
+ assert isinstance(categories, list)
+ logger.info(f"Found {len(categories)} categories via MCP")
+
+ # 4. Get recipes in this category via MCP
+ logger.info(f"Getting recipes in category via MCP: {unique_category}")
+ category_recipes_result = await nc_mcp_client.call_tool(
+ "nc_cookbook_get_recipes_in_category", {"category": unique_category}
+ )
+
+ assert category_recipes_result.isError is False, (
+ f"MCP get recipes in category failed: {category_recipes_result.content}"
+ )
+
+ category_recipes_response = json.loads(category_recipes_result.content[0].text)
+ recipes_in_category = category_recipes_response["recipes"]
+
+ assert isinstance(recipes_in_category, list)
+ assert len(recipes_in_category) > 0
+
+ # 5. Verify our recipe is in the results
+ found = any(
+ str(r.get("id")) == str(created_recipe_id) for r in recipes_in_category
+ )
+ assert found, (
+ f"Recipe {created_recipe_id} not found in category {unique_category}"
+ )
+ logger.info(f"Successfully found recipe in category {unique_category} via MCP")
+
+ finally:
+ # Cleanup
+ if created_recipe_id is not None:
+ try:
+ await nc_client.cookbook.delete_recipe(created_recipe_id)
+ logger.info(f"Cleaned up recipe {created_recipe_id}")
+ except Exception as e:
+ logger.warning(f"Failed to cleanup recipe: {e}")
+
+
+async def test_mcp_cookbook_keywords_workflow(
+ nc_mcp_client: ClientSession, nc_client: NextcloudClient
+):
+ """Test keyword listing and filtering via MCP tools."""
+
+ unique_keyword = f"mcptesttag{uuid.uuid4().hex[:8]}"
+ recipe_name = f"MCP Keyword Test {uuid.uuid4().hex[:8]}"
+ recipe_data = {
+ "name": recipe_name,
+ "keywords": f"{unique_keyword},mcptesting",
+ "recipeIngredient": ["test"],
+ "recipeInstructions": ["test"],
+ }
+
+ created_recipe_id = None
+
+ try:
+ # 1. Create recipe with test keywords
+ logger.info(f"Creating recipe with keyword: {unique_keyword}")
+ created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
+
+ # 2. Allow extra time for indexing and trigger reindex
+ await asyncio.sleep(3)
+ await nc_client.cookbook.reindex()
+ await asyncio.sleep(2)
+
+ # 3. List keywords via MCP
+ logger.info("Listing keywords via MCP")
+ keywords_result = await nc_mcp_client.call_tool("nc_cookbook_list_keywords", {})
+
+ assert keywords_result.isError is False, (
+ f"MCP list keywords failed: {keywords_result.content}"
+ )
+
+ keywords_response = json.loads(keywords_result.content[0].text)
+ keywords = keywords_response["keywords"]
+
+ assert isinstance(keywords, list)
+ logger.info(f"Found {len(keywords)} keywords via MCP")
+
+ # 4. Get recipes with this keyword via MCP
+ logger.info(f"Getting recipes with keyword via MCP: {unique_keyword}")
+ keyword_recipes_result = await nc_mcp_client.call_tool(
+ "nc_cookbook_get_recipes_with_keywords", {"keywords": [unique_keyword]}
+ )
+
+ assert keyword_recipes_result.isError is False, (
+ f"MCP get recipes with keywords failed: {keyword_recipes_result.content}"
+ )
+
+ keyword_recipes_response = json.loads(keyword_recipes_result.content[0].text)
+ recipes_with_keywords = keyword_recipes_response["recipes"]
+
+ assert isinstance(recipes_with_keywords, list)
+
+ # Keyword filtering might not find recipes immediately due to indexing
+ if len(recipes_with_keywords) > 0:
+ # Verify our recipe is in the results if any are found
+ found = any(
+ str(r.get("id")) == str(created_recipe_id)
+ for r in recipes_with_keywords
+ )
+ if found:
+ logger.info(
+ f"Successfully found recipe with keyword {unique_keyword} via MCP"
+ )
+ else:
+ logger.warning(
+ f"Recipe {created_recipe_id} not in keyword results via MCP, but other recipes found"
+ )
+ else:
+ logger.warning(
+ f"No recipes found with keyword {unique_keyword} via MCP - may be indexing delay"
+ )
+
+ finally:
+ # Cleanup
+ if created_recipe_id is not None:
+ try:
+ await nc_client.cookbook.delete_recipe(created_recipe_id)
+ logger.info(f"Cleaned up recipe {created_recipe_id}")
+ except Exception as e:
+ logger.warning(f"Failed to cleanup recipe: {e}")
+
+
+async def test_mcp_cookbook_config_and_version(
+ nc_mcp_client: ClientSession, nc_client: NextcloudClient
+):
+ """Test getting Cookbook configuration and version via MCP resources."""
+
+ # 1. Get version via MCP resource
+ logger.info("Getting Cookbook version via MCP resource")
+ version_result = await nc_mcp_client.read_resource("cookbook://version")
+
+ assert len(version_result.contents) > 0
+ version_response = json.loads(version_result.contents[0].text)
+ assert "cookbook_version" in version_response
+ assert "api_version" in version_response
+ logger.info(f"Cookbook version from MCP: {version_response}")
+
+ # 2. Verify version via direct NextcloudClient
+ direct_version = await nc_client.cookbook.get_version()
+ assert direct_version["cookbook_version"] == version_response["cookbook_version"]
+ assert (
+ direct_version["api_version"]["epoch"]
+ == version_response["api_version"]["epoch"]
+ )
+
+ # 3. Get config via MCP resource
+ logger.info("Getting Cookbook config via MCP resource")
+ config_result = await nc_mcp_client.read_resource("cookbook://config")
+
+ assert len(config_result.contents) > 0
+ config_response = json.loads(config_result.contents[0].text)
+ assert isinstance(config_response, dict)
+ logger.info(f"Cookbook config from MCP: {config_response}")
+
+ # 4. Verify config via direct NextcloudClient
+ direct_config = await nc_client.cookbook.get_config()
+ # Both should be dicts - exact match may vary based on config
+ assert isinstance(config_response, dict)
+ assert isinstance(direct_config, dict)
+
+ logger.info("Successfully verified Cookbook version and config via MCP")
+
+
+async def test_mcp_cookbook_reindex(
+ nc_mcp_client: ClientSession, nc_client: NextcloudClient
+):
+ """Test triggering a recipe reindex via MCP tools."""
+
+ logger.info("Triggering recipe reindex via MCP")
+ reindex_result = await nc_mcp_client.call_tool("nc_cookbook_reindex", {})
+
+ assert reindex_result.isError is False, (
+ f"MCP reindex failed: {reindex_result.content}"
+ )
+
+ reindex_response = json.loads(reindex_result.content[0].text)
+ assert isinstance(reindex_response["message"], str)
+ logger.info(f"Reindex result from MCP: {reindex_response['message']}")
diff --git a/tests/server/test_deck_advanced_features.py b/tests/server/test_deck_advanced_features.py
new file mode 100644
index 0000000..3fa682d
--- /dev/null
+++ b/tests/server/test_deck_advanced_features.py
@@ -0,0 +1,569 @@
+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
+
+
+# Stack MCP Tools Tests
+async def test_deck_stack_mcp_tools(
+ nc_mcp_client: ClientSession, nc_client: NextcloudClient, temporary_board: dict
+):
+ """Test complete deck stack operations via MCP tools."""
+ board_id = temporary_board["id"]
+ stack_title = f"MCP Test Stack {uuid.uuid4().hex[:8]}"
+ stack_order = 1
+
+ # 1. Create stack via MCP tool
+ logger.info(f"Creating stack via MCP: {stack_title}")
+ create_result = await nc_mcp_client.call_tool(
+ "deck_create_stack",
+ {"board_id": board_id, "title": stack_title, "order": stack_order},
+ )
+
+ assert create_result.isError is False, (
+ f"MCP stack creation failed: {create_result.content}"
+ )
+ created_stack_response = json.loads(create_result.content[0].text)
+ stack_id = created_stack_response["id"]
+ assert created_stack_response["title"] == stack_title
+ assert created_stack_response["order"] == stack_order
+ logger.info(f"Stack created via MCP with ID: {stack_id}")
+
+ try:
+ # 2. Get stack via MCP resource
+ logger.info(f"Getting stack via MCP resource: {stack_id}")
+ get_result = await nc_mcp_client.read_resource(
+ f"nc://Deck/boards/{board_id}/stacks/{stack_id}"
+ )
+
+ assert len(get_result.contents) == 1, "Expected exactly one content item"
+ get_stack_response = json.loads(get_result.contents[0].text)
+ assert get_stack_response["title"] == stack_title
+ logger.info("Stack retrieved via MCP resource successfully")
+
+ # 3. Update stack via MCP tool
+ updated_title = f"Updated {stack_title}"
+ updated_order = 2
+ logger.info(f"Updating stack via MCP tool: {stack_id}")
+ update_result = await nc_mcp_client.call_tool(
+ "deck_update_stack",
+ {
+ "board_id": board_id,
+ "stack_id": stack_id,
+ "title": updated_title,
+ "order": updated_order,
+ },
+ )
+
+ assert update_result.isError is False, (
+ f"MCP stack update failed: {update_result.content}"
+ )
+ logger.info("Stack updated via MCP tool successfully")
+
+ # 4. Verify update via direct client
+ updated_stack = await nc_client.deck.get_stack(board_id, stack_id)
+ assert updated_stack.title == updated_title
+ assert updated_stack.order == updated_order
+ logger.info("Stack update verified via direct client")
+
+ # 5. List stacks via MCP resource
+ logger.info("Listing stacks via MCP resource")
+ list_result = await nc_mcp_client.read_resource(
+ f"nc://Deck/boards/{board_id}/stacks"
+ )
+
+ assert len(list_result.contents) == 1, "Expected exactly one content item"
+ stacks_data = json.loads(list_result.contents[0].text)
+ assert isinstance(stacks_data, list)
+
+ # Verify our stack is in the list
+ stack_ids = [stack["id"] for stack in stacks_data]
+ assert stack_id in stack_ids, "Updated stack not found in list"
+ logger.info(f"Stack {stack_id} found in stacks list")
+
+ # 6. Read stack via MCP resource
+ logger.info(f"Reading stack via MCP resource: {stack_id}")
+ read_result = await nc_mcp_client.read_resource(
+ f"nc://Deck/boards/{board_id}/stacks/{stack_id}"
+ )
+ read_stack_data = json.loads(read_result.contents[0].text)
+ assert read_stack_data["title"] == updated_title
+ logger.info("Stack read via MCP resource successfully")
+
+ finally:
+ # Clean up
+ await nc_client.deck.delete_stack(board_id, stack_id)
+ logger.info(f"Cleaned up stack ID: {stack_id}")
+
+
+# Card MCP Tools Tests
+async def test_deck_card_mcp_tools(
+ nc_mcp_client: ClientSession,
+ nc_client: NextcloudClient,
+ temporary_board_with_stack: tuple,
+):
+ """Test complete deck card operations via MCP tools."""
+ board_data, stack_data = temporary_board_with_stack
+ board_id = board_data["id"]
+ stack_id = stack_data["id"]
+ card_title = f"MCP Test Card {uuid.uuid4().hex[:8]}"
+ card_description = f"Test description for {card_title}"
+
+ # 1. Create card via MCP tool
+ logger.info(f"Creating card via MCP: {card_title}")
+ create_result = await nc_mcp_client.call_tool(
+ "deck_create_card",
+ {
+ "board_id": board_id,
+ "stack_id": stack_id,
+ "title": card_title,
+ "description": card_description,
+ "type": "plain",
+ "order": 1,
+ },
+ )
+
+ assert create_result.isError is False, (
+ f"MCP card creation failed: {create_result.content}"
+ )
+ created_card_response = json.loads(create_result.content[0].text)
+ card_id = created_card_response["id"]
+ assert created_card_response["title"] == card_title
+ assert created_card_response["description"] == card_description
+ logger.info(f"Card created via MCP with ID: {card_id}")
+
+ try:
+ # 2. Get card via MCP resource
+ logger.info(f"Getting card via MCP resource: {card_id}")
+ get_result = await nc_mcp_client.read_resource(
+ f"nc://Deck/boards/{board_id}/stacks/{stack_id}/cards/{card_id}"
+ )
+
+ assert len(get_result.contents) == 1, "Expected exactly one content item"
+ get_card_response = json.loads(get_result.contents[0].text)
+ assert get_card_response["title"] == card_title
+ logger.info("Card retrieved via MCP resource successfully")
+
+ # 3. Update card via MCP tool
+ updated_title = f"Updated {card_title}"
+ updated_description = f"Updated description for {card_title}"
+ logger.info(f"Updating card via MCP tool: {card_id}")
+ update_result = await nc_mcp_client.call_tool(
+ "deck_update_card",
+ {
+ "board_id": board_id,
+ "stack_id": stack_id,
+ "card_id": card_id,
+ "title": updated_title,
+ "description": updated_description,
+ },
+ )
+
+ assert update_result.isError is False, (
+ f"MCP card update failed: {update_result.content}"
+ )
+ logger.info("Card updated via MCP tool successfully")
+
+ # 4. Verify update via direct client
+ updated_card = await nc_client.deck.get_card(board_id, stack_id, card_id)
+ assert updated_card.title == updated_title
+ assert updated_card.description == updated_description
+ logger.info("Card update verified via direct client")
+
+ # 5. Archive/unarchive card via MCP tools
+ logger.info(f"Archiving card via MCP tool: {card_id}")
+ archive_result = await nc_mcp_client.call_tool(
+ "deck_archive_card",
+ {"board_id": board_id, "stack_id": stack_id, "card_id": card_id},
+ )
+
+ assert archive_result.isError is False, (
+ f"MCP card archive failed: {archive_result.content}"
+ )
+ logger.info("Card archived via MCP tool successfully")
+
+ logger.info(f"Unarchiving card via MCP tool: {card_id}")
+ unarchive_result = await nc_mcp_client.call_tool(
+ "deck_unarchive_card",
+ {"board_id": board_id, "stack_id": stack_id, "card_id": card_id},
+ )
+
+ assert unarchive_result.isError is False, (
+ f"MCP card unarchive failed: {unarchive_result.content}"
+ )
+ logger.info("Card unarchived via MCP tool successfully")
+
+ # 6. Move card to different position via MCP tool
+ logger.info(f"Reordering card via MCP tool: {card_id}")
+ reorder_result = await nc_mcp_client.call_tool(
+ "deck_reorder_card",
+ {
+ "board_id": board_id,
+ "stack_id": stack_id,
+ "card_id": card_id,
+ "order": 10,
+ "target_stack_id": stack_id,
+ },
+ )
+
+ assert reorder_result.isError is False, (
+ f"MCP card reorder failed: {reorder_result.content}"
+ )
+ logger.info("Card reordered via MCP tool successfully")
+
+ # 7. Read card via MCP resource
+ logger.info(f"Reading card via MCP resource: {card_id}")
+ read_result = await nc_mcp_client.read_resource(
+ f"nc://Deck/boards/{board_id}/stacks/{stack_id}/cards/{card_id}"
+ )
+ read_card_data = json.loads(read_result.contents[0].text)
+ assert read_card_data["title"] == updated_title
+ logger.info("Card read via MCP resource successfully")
+
+ finally:
+ # Clean up
+ await nc_client.deck.delete_card(board_id, stack_id, card_id)
+ logger.info(f"Cleaned up card ID: {card_id}")
+
+
+# Label MCP Tools Tests
+async def test_deck_label_mcp_tools(
+ nc_mcp_client: ClientSession, nc_client: NextcloudClient, temporary_board: dict
+):
+ """Test complete deck label operations via MCP tools."""
+ board_id = temporary_board["id"]
+ label_title = f"MCP Test Label {uuid.uuid4().hex[:8]}"
+ label_color = "FF0000" # Red
+
+ # 1. Create label via MCP tool
+ logger.info(f"Creating label via MCP: {label_title}")
+ create_result = await nc_mcp_client.call_tool(
+ "deck_create_label",
+ {"board_id": board_id, "title": label_title, "color": label_color},
+ )
+
+ assert create_result.isError is False, (
+ f"MCP label creation failed: {create_result.content}"
+ )
+ created_label_response = json.loads(create_result.content[0].text)
+ label_id = created_label_response["id"]
+ assert created_label_response["title"] == label_title
+ assert created_label_response["color"] == label_color
+ logger.info(f"Label created via MCP with ID: {label_id}")
+
+ try:
+ # 2. Get label via MCP resource
+ logger.info(f"Getting label via MCP resource: {label_id}")
+ get_result = await nc_mcp_client.read_resource(
+ f"nc://Deck/boards/{board_id}/labels/{label_id}"
+ )
+
+ assert len(get_result.contents) == 1, "Expected exactly one content item"
+ get_label_response = json.loads(get_result.contents[0].text)
+ assert get_label_response["title"] == label_title
+ logger.info("Label retrieved via MCP resource successfully")
+
+ # 3. Update label via MCP tool
+ updated_title = f"Updated {label_title}"
+ updated_color = "00FF00" # Green
+ logger.info(f"Updating label via MCP tool: {label_id}")
+ update_result = await nc_mcp_client.call_tool(
+ "deck_update_label",
+ {
+ "board_id": board_id,
+ "label_id": label_id,
+ "title": updated_title,
+ "color": updated_color,
+ },
+ )
+
+ assert update_result.isError is False, (
+ f"MCP label update failed: {update_result.content}"
+ )
+ logger.info("Label updated via MCP tool successfully")
+
+ # 4. Verify update via direct client
+ updated_label = await nc_client.deck.get_label(board_id, label_id)
+ assert updated_label.title == updated_title
+ assert updated_label.color == updated_color
+ logger.info("Label update verified via direct client")
+
+ # 5. Read label via MCP resource
+ logger.info(f"Reading label via MCP resource: {label_id}")
+ read_result = await nc_mcp_client.read_resource(
+ f"nc://Deck/boards/{board_id}/labels/{label_id}"
+ )
+ read_label_data = json.loads(read_result.contents[0].text)
+ assert read_label_data["title"] == updated_title
+ logger.info("Label read via MCP resource successfully")
+
+ finally:
+ # Clean up
+ await nc_client.deck.delete_label(board_id, label_id)
+ logger.info(f"Cleaned up label ID: {label_id}")
+
+
+# Label-Card Assignment Tests
+async def test_deck_card_label_assignment_mcp_tools(
+ nc_mcp_client: ClientSession,
+ nc_client: NextcloudClient,
+ temporary_board_with_card: tuple,
+):
+ """Test card-label assignment operations via MCP tools."""
+ board_data, stack_data, card_data = temporary_board_with_card
+ board_id = board_data["id"]
+ stack_id = stack_data["id"]
+ card_id = card_data["id"]
+
+ # Create a label for assignment
+ label = await nc_client.deck.create_label(
+ board_id, "Assignment Test Label", "0000FF"
+ )
+ label_id = label.id
+
+ try:
+ # 1. Assign label to card via MCP tool
+ logger.info(f"Assigning label {label_id} to card {card_id} via MCP")
+ assign_result = await nc_mcp_client.call_tool(
+ "deck_assign_label_to_card",
+ {
+ "board_id": board_id,
+ "stack_id": stack_id,
+ "card_id": card_id,
+ "label_id": label_id,
+ },
+ )
+
+ assert assign_result.isError is False, (
+ f"MCP label assignment failed: {assign_result.content}"
+ )
+ logger.info("Label assigned to card via MCP tool successfully")
+
+ # 2. Verify assignment via direct client
+ card = await nc_client.deck.get_card(board_id, stack_id, card_id)
+ if card.labels:
+ label_ids = [label.id for label in card.labels]
+ assert label_id in label_ids, "Label not found in card labels"
+ logger.info("Label assignment verified via direct client")
+
+ # 3. Remove label from card via MCP tool
+ logger.info(f"Removing label {label_id} from card {card_id} via MCP")
+ remove_result = await nc_mcp_client.call_tool(
+ "deck_remove_label_from_card",
+ {
+ "board_id": board_id,
+ "stack_id": stack_id,
+ "card_id": card_id,
+ "label_id": label_id,
+ },
+ )
+
+ assert remove_result.isError is False, (
+ f"MCP label removal failed: {remove_result.content}"
+ )
+ logger.info("Label removed from card via MCP tool successfully")
+
+ # 4. Verify removal via direct client
+ card = await nc_client.deck.get_card(board_id, stack_id, card_id)
+ if card.labels:
+ label_ids = [label.id for label in card.labels]
+ assert label_id not in label_ids, (
+ "Label still found in card labels after removal"
+ )
+ logger.info("Label removal verified via direct client")
+
+ finally:
+ # Clean up
+ await nc_client.deck.delete_label(board_id, label_id)
+ logger.info(f"Cleaned up label ID: {label_id}")
+
+
+# User Assignment Tests
+async def test_deck_card_user_assignment_mcp_tools(
+ nc_mcp_client: ClientSession,
+ nc_client: NextcloudClient,
+ temporary_board_with_card: tuple,
+):
+ """Test card-user assignment operations via MCP tools."""
+ board_data, stack_data, card_data = temporary_board_with_card
+ board_id = board_data["id"]
+ stack_id = stack_data["id"]
+ card_id = card_data["id"]
+
+ # Use the current user ID (admin in most test environments)
+ user_id = "admin"
+
+ # 1. Assign user to card via MCP tool
+ logger.info(f"Assigning user {user_id} to card {card_id} via MCP")
+ assign_result = await nc_mcp_client.call_tool(
+ "deck_assign_user_to_card",
+ {
+ "board_id": board_id,
+ "stack_id": stack_id,
+ "card_id": card_id,
+ "user_id": user_id,
+ },
+ )
+
+ assert assign_result.isError is False, (
+ f"MCP user assignment failed: {assign_result.content}"
+ )
+ logger.info("User assigned to card via MCP tool successfully")
+
+ # 2. Verify assignment via direct client
+ card = await nc_client.deck.get_card(board_id, stack_id, card_id)
+ if card.assignedUsers:
+ user_ids = []
+ for user in card.assignedUsers:
+ if hasattr(user, "participant"):
+ # It's a DeckAssignedUser with participant
+ user_ids.append(user.participant.uid)
+ elif hasattr(user, "uid"):
+ # It's a direct DeckUser
+ user_ids.append(user.uid)
+ assert user_id in user_ids, "User not found in card assigned users"
+ logger.info("User assignment verified via direct client")
+
+ # 3. Unassign user from card via MCP tool
+ logger.info(f"Unassigning user {user_id} from card {card_id} via MCP")
+ unassign_result = await nc_mcp_client.call_tool(
+ "deck_unassign_user_from_card",
+ {
+ "board_id": board_id,
+ "stack_id": stack_id,
+ "card_id": card_id,
+ "user_id": user_id,
+ },
+ )
+
+ assert unassign_result.isError is False, (
+ f"MCP user unassignment failed: {unassign_result.content}"
+ )
+ logger.info("User unassigned from card via MCP tool successfully")
+
+ # 4. Verify unassignment via direct client
+ card = await nc_client.deck.get_card(board_id, stack_id, card_id)
+ if card.assignedUsers:
+ user_ids = []
+ for user in card.assignedUsers:
+ if hasattr(user, "participant"):
+ # It's a DeckAssignedUser with participant
+ user_ids.append(user.participant.uid)
+ elif hasattr(user, "uid"):
+ # It's a direct DeckUser
+ user_ids.append(user.uid)
+ assert user_id not in user_ids, (
+ "User still found in card assigned users after removal"
+ )
+ logger.info("User unassignment verified via direct client")
+
+
+# Error handling tests
+async def test_deck_mcp_tools_error_handling(nc_mcp_client: ClientSession):
+ """Test error handling for deck MCP tools with invalid parameters."""
+ non_existent_id = 999999999
+
+ # Test stack operations with non-existent board
+ stack_result = await nc_mcp_client.call_tool(
+ "deck_create_stack",
+ {"board_id": non_existent_id, "title": "Should Fail", "order": 1},
+ )
+ assert stack_result.isError is True, (
+ "Expected error for stack creation on non-existent board"
+ )
+
+ # Test card operations with non-existent IDs
+ card_result = await nc_mcp_client.call_tool(
+ "deck_create_card",
+ {
+ "board_id": non_existent_id,
+ "stack_id": non_existent_id,
+ "title": "Should Fail",
+ "type": "plain",
+ },
+ )
+ assert card_result.isError is True, (
+ "Expected error for card creation with non-existent IDs"
+ )
+
+ # Test label operations with non-existent board
+ label_result = await nc_mcp_client.call_tool(
+ "deck_create_label",
+ {"board_id": non_existent_id, "title": "Should Fail", "color": "FF0000"},
+ )
+ assert label_result.isError is True, (
+ "Expected error for label creation on non-existent board"
+ )
+
+ logger.info("Error handling tests passed for deck MCP tools")
+
+
+# Resource template tests
+async def test_deck_mcp_resource_templates(nc_mcp_client: ClientSession):
+ """Test deck MCP resource templates are properly registered."""
+ templates = await nc_mcp_client.list_resource_templates()
+ template_uris = [template.uriTemplate for template in templates.resourceTemplates]
+
+ expected_templates = [
+ "nc://Deck/boards/{board_id}/stacks/{stack_id}",
+ "nc://Deck/boards/{board_id}/stacks/{stack_id}/cards/{card_id}",
+ "nc://Deck/boards/{board_id}/labels/{label_id}",
+ ]
+
+ for expected_template in expected_templates:
+ assert expected_template in template_uris, (
+ f"Expected template '{expected_template}' not found"
+ )
+ logger.info(f"Found expected deck resource template: {expected_template}")
+
+
+# Listing resource tests
+async def test_deck_mcp_listing_resources(
+ nc_mcp_client: ClientSession, temporary_board_with_card: tuple
+):
+ """Test deck MCP listing resources for stacks and cards."""
+ board_data, stack_data, card_data = temporary_board_with_card
+ board_id = board_data["id"]
+ stack_id = stack_data["id"]
+
+ # 1. Test listing stacks resource
+ logger.info(f"Reading stacks list via MCP resource for board {board_id}")
+ stacks_resource_result = await nc_mcp_client.read_resource(
+ f"nc://Deck/boards/{board_id}/stacks"
+ )
+ stacks_resource_data = json.loads(stacks_resource_result.contents[0].text)
+ assert isinstance(stacks_resource_data, list)
+
+ # Verify our stack is in the resource list
+ stack_ids = [stack["id"] for stack in stacks_resource_data]
+ assert stack_id in stack_ids, "Stack not found in stacks resource list"
+ logger.info("Stack found in stacks resource list")
+
+ # 2. Test listing cards resource
+ logger.info(f"Reading cards list via MCP resource for stack {stack_id}")
+ cards_resource_result = await nc_mcp_client.read_resource(
+ f"nc://Deck/boards/{board_id}/stacks/{stack_id}/cards"
+ )
+ cards_resource_data = json.loads(cards_resource_result.contents[0].text)
+ assert isinstance(cards_resource_data, list)
+
+ # Verify our card is in the resource list
+ card_ids = [card["id"] for card in cards_resource_data]
+ assert card_data["id"] in card_ids, "Card not found in cards resource list"
+ logger.info("Card found in cards resource list")
+
+ # 3. Test listing labels resource
+ logger.info(f"Reading labels list via MCP resource for board {board_id}")
+ labels_resource_result = await nc_mcp_client.read_resource(
+ f"nc://Deck/boards/{board_id}/labels"
+ )
+ labels_resource_data = json.loads(labels_resource_result.contents[0].text)
+ assert isinstance(labels_resource_data, list)
+ logger.info("Labels resource read successfully")
diff --git a/tests/integration/test_deck_mcp.py b/tests/server/test_deck_mcp.py
similarity index 100%
rename from tests/integration/test_deck_mcp.py
rename to tests/server/test_deck_mcp.py
diff --git a/tests/integration/test_error_propagation.py b/tests/server/test_error_propagation.py
similarity index 96%
rename from tests/integration/test_error_propagation.py
rename to tests/server/test_error_propagation.py
index 8cf6667..cc9b48d 100644
--- a/tests/integration/test_error_propagation.py
+++ b/tests/server/test_error_propagation.py
@@ -1,14 +1,16 @@
"""Test error propagation in the MCP server for various error scenarios."""
import logging
-from mcp import ClientSession
import pytest
+from mcp import ClientSession
logger = logging.getLogger(__name__)
+# Mark all tests in this module as integration tests
+pytestmark = pytest.mark.integration
+
-@pytest.mark.integration
async def test_missing_note_tool_error(nc_mcp_client: ClientSession):
"""Test that accessing a non-existent note via tool returns proper error."""
# Try to get a non-existent note via tool - should return error response
@@ -20,7 +22,6 @@ async def test_missing_note_tool_error(nc_mcp_client: ClientSession):
assert "Note 999999 not found" in response.content[0].text
-@pytest.mark.integration
async def test_delete_missing_note_tool_error(nc_mcp_client: ClientSession):
"""Test that deleting a non-existent note returns proper error."""
# Try to delete a non-existent note - should return error response
@@ -34,7 +35,6 @@ async def test_delete_missing_note_tool_error(nc_mcp_client: ClientSession):
assert "Note 999999 not found" in response.content[0].text
-@pytest.mark.integration
async def test_search_with_empty_query(nc_mcp_client: ClientSession):
"""Test search behavior with empty query."""
# Search with empty query
@@ -47,7 +47,6 @@ async def test_search_with_empty_query(nc_mcp_client: ClientSession):
assert response.isError is False
-@pytest.mark.integration
async def test_tool_missing_required_parameters(nc_mcp_client: ClientSession):
"""Test calling a tool with missing required parameters."""
# Try to create note with missing parameters
@@ -66,7 +65,6 @@ async def test_tool_missing_required_parameters(nc_mcp_client: ClientSession):
)
-@pytest.mark.integration
async def test_update_note_with_invalid_etag(nc_mcp_client: ClientSession, nc_client):
"""Test updating a note with invalid ETag."""
# First create a note
@@ -98,7 +96,6 @@ async def test_update_note_with_invalid_etag(nc_mcp_client: ClientSession, nc_cl
await nc_client.notes.delete_note(note_id)
-@pytest.mark.integration
async def test_calendar_missing_calendar_error(nc_mcp_client: ClientSession):
"""Test calendar operations with non-existent calendar."""
# Try to create event in non-existent calendar
@@ -127,7 +124,6 @@ async def test_calendar_missing_calendar_error(nc_mcp_client: ClientSession):
assert response.isError is True
-@pytest.mark.integration
async def test_webdav_read_missing_file_error(nc_mcp_client: ClientSession):
"""Test WebDAV operations with non-existent file."""
# Try to read a non-existent file
@@ -151,7 +147,6 @@ async def test_webdav_read_missing_file_error(nc_mcp_client: ClientSession):
assert response.isError is True
-@pytest.mark.integration
async def test_tables_missing_table_error(nc_mcp_client: ClientSession):
"""Test Tables operations with non-existent table."""
# Try to get schema of non-existent table
diff --git a/tests/integration/test_mcp.py b/tests/server/test_mcp.py
similarity index 96%
rename from tests/integration/test_mcp.py
rename to tests/server/test_mcp.py
index c3074fe..ff9a310 100644
--- a/tests/integration/test_mcp.py
+++ b/tests/server/test_mcp.py
@@ -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,7 +57,25 @@ 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",
+ "nc_cookbook_get_recipe",
+ "nc_cookbook_create_recipe",
+ "nc_cookbook_update_recipe",
+ "nc_cookbook_delete_recipe",
+ "nc_cookbook_search_recipes",
+ "nc_cookbook_list_categories",
+ "nc_cookbook_get_recipes_in_category",
+ "nc_cookbook_list_keywords",
+ "nc_cookbook_get_recipes_with_keywords",
+ "nc_cookbook_set_config",
+ "nc_cookbook_reindex",
]
for expected_tool in expected_tools:
@@ -85,7 +109,13 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession):
resource_uris.append(str(resource.uri)) # Convert to string for comparison
# Verify expected resources
- expected_resources = ["nc://capabilities", "notes://settings", "nc://Deck/boards"]
+ expected_resources = [
+ "nc://capabilities",
+ "notes://settings",
+ "nc://Deck/boards",
+ "cookbook://version",
+ "cookbook://config",
+ ]
for expected_resource in expected_resources:
assert expected_resource in resource_uris, (
diff --git a/tests/server/test_mcp_oauth.py b/tests/server/test_mcp_oauth.py
new file mode 100644
index 0000000..308b3dd
--- /dev/null
+++ b/tests/server/test_mcp_oauth.py
@@ -0,0 +1,60 @@
+import json
+import logging
+
+import pytest
+
+logger = logging.getLogger(__name__)
+
+pytestmark = [pytest.mark.integration, pytest.mark.oauth]
+
+
+async def test_mcp_oauth_server_connection(nc_mcp_oauth_client):
+ """Test connection to OAuth-enabled MCP server."""
+ result = await nc_mcp_oauth_client.list_tools()
+ assert result is not None
+ assert len(result.tools) > 0
+
+ logger.info(f"OAuth MCP server has {len(result.tools)} tools available")
+
+
+async def test_mcp_oauth_tool_execution(nc_mcp_oauth_client):
+ """Test executing a tool on the OAuth-enabled MCP server."""
+ import json
+
+ # Example: Execute the 'nc_notes_search_notes' tool
+ result = await nc_mcp_oauth_client.call_tool(
+ "nc_notes_search_notes", arguments={"query": ""}
+ )
+
+ assert result.isError is False, f"Tool execution failed: {result.content}"
+ assert result.content is not None
+ response_data = json.loads(result.content[0].text)
+
+ # The search response should have a 'results' field containing the list
+ assert "results" in response_data
+ assert isinstance(response_data["results"], list)
+
+ logger.info(
+ f"Successfully executed 'nc_notes_search_notes' tool on OAuth MCP server and got {len(response_data['results'])} notes."
+ )
+
+
+async def test_mcp_oauth_client_with_playwright(nc_mcp_oauth_client):
+ """Test that MCP OAuth client via Playwright can execute tools."""
+
+ # Test: Execute the 'nc_notes_search_notes' tool
+ result = await nc_mcp_oauth_client.call_tool(
+ "nc_notes_search_notes", arguments={"query": ""}
+ )
+
+ assert result.isError is False, f"Tool execution failed: {result.content}"
+ assert result.content is not None
+ response_data = json.loads(result.content[0].text)
+
+ # The search response should have a 'results' field containing the list
+ assert "results" in response_data
+ assert isinstance(response_data["results"], list)
+
+ logger.info(
+ f"Successfully executed 'nc_notes_search_notes' tool on Playwright OAuth MCP server and got {len(response_data['results'])} notes."
+ )
diff --git a/tests/server/test_oauth_deck_permissions.py b/tests/server/test_oauth_deck_permissions.py
new file mode 100644
index 0000000..ae048ea
--- /dev/null
+++ b/tests/server/test_oauth_deck_permissions.py
@@ -0,0 +1,358 @@
+"""
+Multi-user OAuth tests for Nextcloud Deck board permissions.
+
+Tests verify that the MCP server respects Nextcloud Deck board ACL permissions
+when accessed via OAuth authentication with different users.
+"""
+
+import json
+import logging
+
+import pytest
+
+logger = logging.getLogger(__name__)
+
+pytestmark = [pytest.mark.integration, pytest.mark.oauth]
+
+
+async def add_board_acl(nc_client, board_id: int, user: str, permission_type: int = 0):
+ """
+ Helper to add ACL entry to a Deck board.
+
+ Args:
+ nc_client: Admin NextcloudClient
+ board_id: Board ID
+ user: Username to grant access
+ permission_type: 0=view, 1=edit, 2=manage
+
+ Returns:
+ ACL entry ID
+ """
+ acl = await nc_client.deck.add_acl_rule(
+ board_id=board_id,
+ type=0, # 0 = user, 1 = group
+ participant=user,
+ permission_edit=permission_type >= 1,
+ permission_share=permission_type >= 2,
+ permission_manage=permission_type >= 2,
+ )
+ logger.info(f"Added ACL for board {board_id}: {user} (type={permission_type})")
+ return acl.id
+
+
+async def delete_board_acl(nc_client, board_id: int, acl_id: int):
+ """Helper to delete a board ACL entry."""
+ await nc_client.deck.delete_acl_rule(board_id, acl_id)
+ logger.info(f"Deleted ACL {acl_id} from board {board_id}")
+
+
+@pytest.mark.anyio
+async def test_deck_board_view_permissions(
+ nc_client, alice_mcp_client, bob_mcp_client, diana_mcp_client
+):
+ """
+ Test that Deck boards respect view permissions.
+
+ Scenario:
+ 1. Admin creates a board as alice
+ 2. Admin adds bob to board with view-only permissions
+ 3. Bob can view the board via MCP tools
+ 4. Diana cannot access the board (no ACL entry)
+ """
+ # Create a board as alice
+ logger.info("Creating Deck board as alice...")
+ board = await nc_client.deck.create_board(
+ "Alice's Shared Board - View Test", "FF0000"
+ )
+ board_id = board.id
+
+ bob_acl_id = None
+
+ try:
+ # Add bob to board with view-only permission
+ logger.info("Adding bob to board with view permission...")
+ bob_acl_id = await add_board_acl(nc_client, board_id, "bob", permission_type=0)
+
+ # Test: Bob can view the board via MCP
+ logger.info("Bob attempting to list boards via MCP...")
+ result = await bob_mcp_client.call_tool("deck_get_boards", arguments={})
+
+ if not result.isError:
+ response_data = json.loads(result.content[0].text)
+ # The response is directly a list of boards
+ if not isinstance(response_data, list):
+ response_data = [response_data] if response_data else []
+ board_ids = [b["id"] for b in response_data]
+ logger.info(f"Bob can see {len(response_data)} boards: {board_ids}")
+
+ # Bob should see the shared board
+ if board_id in board_ids:
+ logger.info(f"Bob can see shared board {board_id}")
+ else:
+ logger.warning(f"Bob cannot see shared board {board_id}")
+ else:
+ logger.warning(f"Bob could not list boards: {result.content}")
+
+ # Test: Diana cannot see the board
+ logger.info("Diana attempting to list boards via MCP...")
+ result = await diana_mcp_client.call_tool("deck_get_boards", arguments={})
+
+ if not result.isError:
+ response_data = json.loads(result.content[0].text)
+ # The response is directly a list of boards
+ if not isinstance(response_data, list):
+ response_data = [response_data] if response_data else []
+ board_ids = [b["id"] for b in response_data]
+ logger.info(f"Diana can see {len(response_data)} boards")
+
+ # Diana should NOT see the board
+ assert board_id not in board_ids, "Diana should not see board without ACL"
+ logger.info("Diana correctly cannot see board without ACL")
+ else:
+ logger.warning(f"Diana could not list boards: {result.content}")
+
+ finally:
+ # Cleanup
+ if bob_acl_id:
+ await delete_board_acl(nc_client, board_id, bob_acl_id)
+ logger.info(f"Deleting board {board_id}")
+ await nc_client.deck.delete_board(board_id)
+
+
+@pytest.mark.anyio
+async def test_deck_board_edit_permissions(
+ nc_client, alice_mcp_client, charlie_mcp_client, bob_mcp_client
+):
+ """
+ Test that Deck boards respect edit permissions.
+
+ Scenario:
+ 1. Admin creates a board as alice with a stack
+ 2. Admin adds charlie with edit permission
+ 3. Admin adds bob with view-only permission
+ 4. Charlie can create cards via MCP tools
+ 5. Bob cannot create cards
+ """
+ # Create a board as alice
+ logger.info("Creating Deck board as alice...")
+ board = await nc_client.deck.create_board(
+ "Alice's Shared Board - Edit Test", "00FF00"
+ )
+ board_id = board.id
+
+ # Create a stack in the board
+ logger.info("Creating stack in board...")
+ stack = await nc_client.deck.create_stack(board_id, "Test Stack", 1)
+ stack_id = stack.id
+
+ charlie_acl_id = None
+ bob_acl_id = None
+
+ try:
+ # Add charlie with edit permission
+ logger.info("Adding charlie to board with edit permission...")
+ charlie_acl_id = await add_board_acl(
+ nc_client, board_id, "charlie", permission_type=1
+ )
+
+ # Add bob with view-only permission
+ logger.info("Adding bob to board with view permission...")
+ bob_acl_id = await add_board_acl(nc_client, board_id, "bob", permission_type=0)
+
+ # Test: Charlie can create a card
+ logger.info("Charlie attempting to create card via MCP...")
+ result = await charlie_mcp_client.call_tool(
+ "deck_create_card",
+ arguments={
+ "board_id": board_id,
+ "stack_id": stack_id,
+ "title": "Charlie's Card",
+ "description": "Created by Charlie with edit permission",
+ },
+ )
+
+ if not result.isError:
+ response_data = json.loads(result.content[0].text)
+ card_id = response_data.get("id")
+ logger.info(f"Charlie successfully created card {card_id}")
+
+ # Cleanup the card
+ await nc_client.deck.delete_card(board_id, stack_id, card_id)
+ else:
+ logger.warning(f"Charlie could not create card: {result.content}")
+
+ # Test: Bob attempts to create a card (should fail)
+ logger.info("Bob attempting to create card via MCP...")
+ result = await bob_mcp_client.call_tool(
+ "deck_create_card",
+ arguments={
+ "board_id": board_id,
+ "stack_id": stack_id,
+ "title": "Bob's Card",
+ "description": "Bob trying to create a card",
+ },
+ )
+
+ if result.isError:
+ logger.info("Bob correctly denied card creation (view-only)")
+ else:
+ logger.warning("Bob unexpectedly succeeded in creating card")
+ # Cleanup if bob somehow created a card
+ response_data = json.loads(result.content[0].text)
+ if "id" in response_data:
+ await nc_client.deck.delete_card(
+ board_id, stack_id, response_data["id"]
+ )
+
+ finally:
+ # Cleanup
+ if charlie_acl_id:
+ await delete_board_acl(nc_client, board_id, charlie_acl_id)
+ if bob_acl_id:
+ await delete_board_acl(nc_client, board_id, bob_acl_id)
+ logger.info(f"Deleting board {board_id}")
+ await nc_client.deck.delete_board(board_id)
+
+
+@pytest.mark.anyio
+async def test_deck_board_manage_permissions(
+ nc_client, alice_mcp_client, charlie_mcp_client
+):
+ """
+ Test that Deck boards respect manage permissions.
+
+ Scenario:
+ 1. Admin creates a board as alice
+ 2. Admin adds charlie with manage permission
+ 3. Charlie can create stacks and modify board settings
+ """
+ # Create a board as alice
+ logger.info("Creating Deck board as alice...")
+ board = await nc_client.deck.create_board(
+ "Alice's Shared Board - Manage Test", "0000FF"
+ )
+ board_id = board.id
+
+ charlie_acl_id = None
+
+ try:
+ # Add charlie with manage permission
+ logger.info("Adding charlie to board with manage permission...")
+ charlie_acl_id = await add_board_acl(
+ nc_client, board_id, "charlie", permission_type=2
+ )
+
+ # Test: Charlie can create a stack
+ logger.info("Charlie attempting to create stack via MCP...")
+ result = await charlie_mcp_client.call_tool(
+ "deck_create_stack",
+ arguments={"board_id": board_id, "title": "Charlie's Stack", "order": 1},
+ )
+
+ if not result.isError:
+ response_data = json.loads(result.content[0].text)
+ stack_id = response_data.get("id")
+ logger.info(f"Charlie successfully created stack {stack_id}")
+
+ # Cleanup the stack
+ await nc_client.deck.delete_stack(board_id, stack_id)
+ else:
+ logger.warning(f"Charlie could not create stack: {result.content}")
+
+ # Test: Charlie can delete a stack (manage permission)
+ logger.info("Charlie attempting to delete stack via MCP...")
+ # First create a temporary stack to delete
+ temp_stack = await nc_client.deck.create_stack(
+ board_id, "Temp Stack for Deletion", 99
+ )
+
+ result = await charlie_mcp_client.call_tool(
+ "deck_delete_stack",
+ arguments={"board_id": board_id, "stack_id": temp_stack.id},
+ )
+
+ if not result.isError:
+ logger.info("Charlie successfully deleted stack")
+ else:
+ logger.warning(f"Charlie could not delete stack: {result.content}")
+ # Cleanup if deletion via MCP failed
+ try:
+ await nc_client.deck.delete_stack(board_id, temp_stack.id)
+ except Exception:
+ pass
+
+ finally:
+ # Cleanup
+ if charlie_acl_id:
+ await delete_board_acl(nc_client, board_id, charlie_acl_id)
+ logger.info(f"Deleting board {board_id}")
+ await nc_client.deck.delete_board(board_id)
+
+
+@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.
+
+ Scenario:
+ 1. Admin creates a board as alice (not shared)
+ 2. Admin creates a board as bob (not shared)
+ 3. Alice can only see her own board
+ 4. Bob can only see his own board
+ """
+ # Create alice's board
+ logger.info("Creating alice's private board...")
+ alice_board = await nc_client.deck.create_board("Alice's Private Board", "FF00FF")
+ alice_board_id = alice_board.id
+
+ # Create bob's board
+ logger.info("Creating bob's private board...")
+ bob_board = await nc_client.deck.create_board("Bob's Private Board", "00FFFF")
+ bob_board_id = bob_board.id
+
+ try:
+ # Test: Alice lists boards
+ logger.info("Alice listing boards via MCP...")
+ result = await alice_mcp_client.call_tool("deck_get_boards", arguments={})
+
+ if not result.isError:
+ response_data = json.loads(result.content[0].text)
+ # The response is directly a list of boards
+ if not isinstance(response_data, list):
+ response_data = [response_data] if response_data else []
+ board_ids = [b["id"] for b in response_data]
+ logger.info(f"Alice can see boards: {board_ids}")
+
+ # Alice should NOT see Bob's board
+ assert bob_board_id not in board_ids, (
+ "Alice should not see Bob's private board"
+ )
+ else:
+ logger.warning(f"Alice could not list boards: {result.content}")
+
+ # Test: Bob lists boards
+ logger.info("Bob listing boards via MCP...")
+ result = await bob_mcp_client.call_tool("deck_get_boards", arguments={})
+
+ if not result.isError:
+ response_data = json.loads(result.content[0].text)
+ # The response is directly a list of boards
+ if not isinstance(response_data, list):
+ response_data = [response_data] if response_data else []
+ board_ids = [b["id"] for b in response_data]
+ logger.info(f"Bob can see boards: {board_ids}")
+
+ # Bob should NOT see Alice's board
+ assert alice_board_id not in board_ids, (
+ "Bob should not see Alice's private board"
+ )
+ else:
+ logger.warning(f"Bob could not list boards: {result.content}")
+
+ logger.info("User isolation test passed: users can only see their own boards")
+
+ finally:
+ # Cleanup
+ logger.info("Cleaning up test boards...")
+ await nc_client.deck.delete_board(alice_board_id)
+ await nc_client.deck.delete_board(bob_board_id)
diff --git a/tests/server/test_oauth_file_permissions.py b/tests/server/test_oauth_file_permissions.py
new file mode 100644
index 0000000..79982eb
--- /dev/null
+++ b/tests/server/test_oauth_file_permissions.py
@@ -0,0 +1,425 @@
+"""
+Multi-user OAuth tests for Nextcloud WebDAV file permissions.
+
+Tests verify that the MCP server respects Nextcloud file sharing permissions
+when accessed via OAuth authentication with different users.
+
+All operations (file creation, sharing, access) are performed through MCP tools
+to ensure the MCP server properly supports multi-user scenarios.
+"""
+
+import json
+import logging
+
+import pytest
+
+logger = logging.getLogger(__name__)
+
+pytestmark = [pytest.mark.integration, pytest.mark.oauth]
+
+
+@pytest.mark.anyio
+async def test_file_share_read_permissions(
+ alice_mcp_client, bob_mcp_client, diana_mcp_client
+):
+ """
+ Test that shared files respect read permissions.
+
+ Scenario:
+ 1. Alice creates a file via MCP
+ 2. Alice shares the file with Bob (read-only) via MCP
+ 3. Bob can read the file via MCP tools
+ 4. Diana cannot access the file (no share)
+ """
+ file_path = "/alice_shared_file_read.txt"
+ file_content = "This file is shared with Bob for reading only."
+
+ # Alice creates a file
+ logger.info(f"Alice creating file: {file_path}")
+ result = await alice_mcp_client.call_tool(
+ "nc_webdav_write_file",
+ arguments={"path": file_path, "content": file_content},
+ )
+ assert not result.isError, f"Alice failed to create file: {result.content}"
+
+ share_id = None
+
+ try:
+ # Alice shares the file with bob (read-only, permissions=1)
+ logger.info("Alice sharing file with bob (read-only)...")
+ result = await alice_mcp_client.call_tool(
+ "nc_share_create",
+ arguments={
+ "path": file_path,
+ "share_with": "bob",
+ "share_type": 0,
+ "permissions": 1,
+ },
+ )
+ assert not result.isError, f"Alice failed to create share: {result.content}"
+ share_data = json.loads(result.content[0].text)
+ share_id = share_data["id"]
+ logger.info(f"Created share {share_id}")
+
+ # Test: Bob reads the file via MCP
+ logger.info("Bob attempting to read file via MCP...")
+ result = await bob_mcp_client.call_tool(
+ "nc_webdav_read_file", arguments={"path": file_path}
+ )
+
+ # Bob should be able to read the shared file
+ if not result.isError:
+ response_data = json.loads(result.content[0].text)
+ logger.info(
+ f"Bob successfully read file: {response_data.get('content', '')[:50]}..."
+ )
+ assert "content" in response_data
+ assert file_content in response_data["content"]
+ else:
+ logger.warning(f"Bob could not read file: {result.content}")
+ # This might fail if the share path is different for bob
+
+ # Test: Diana attempts to read the file
+ logger.info("Diana attempting to read file via MCP...")
+ result = await diana_mcp_client.call_tool(
+ "nc_webdav_read_file", arguments={"path": file_path}
+ )
+
+ # Diana should NOT be able to read (no share)
+ if result.isError:
+ logger.info("Diana correctly denied access to unshared file")
+ else:
+ logger.warning("Diana unexpectedly could read unshared file")
+
+ finally:
+ # Cleanup - Alice deletes the share and file
+ if share_id:
+ logger.info(f"Alice deleting share {share_id}")
+ await alice_mcp_client.call_tool(
+ "nc_share_delete", arguments={"share_id": share_id}
+ )
+ logger.info(f"Alice deleting file {file_path}")
+ await alice_mcp_client.call_tool(
+ "nc_webdav_delete_resource", arguments={"path": file_path}
+ )
+
+
+@pytest.mark.anyio
+async def test_file_share_write_permissions(
+ alice_mcp_client, charlie_mcp_client, bob_mcp_client
+):
+ """
+ Test that shared files respect write permissions.
+
+ Scenario:
+ 1. Alice creates a file via MCP
+ 2. Alice shares the file with Charlie (edit permission) via MCP
+ 3. Alice shares the file with Bob (read-only) via MCP
+ 4. Charlie can edit the file via MCP tools
+ 5. Bob cannot edit the file
+ """
+ file_path = "/alice_shared_file_write.txt"
+ file_content = "This file is shared with Charlie for editing."
+
+ logger.info(f"Alice creating file: {file_path}")
+ result = await alice_mcp_client.call_tool(
+ "nc_webdav_write_file",
+ arguments={"path": file_path, "content": file_content},
+ )
+ assert not result.isError, f"Alice failed to create file: {result.content}"
+
+ charlie_share_id = None
+ bob_share_id = None
+
+ try:
+ # Alice shares with Charlie (read+write, permissions=3)
+ logger.info("Alice sharing file with Charlie (edit permission)...")
+ result = await alice_mcp_client.call_tool(
+ "nc_share_create",
+ arguments={
+ "path": file_path,
+ "share_with": "charlie",
+ "share_type": 0,
+ "permissions": 3,
+ },
+ )
+ assert not result.isError, (
+ f"Alice failed to share with Charlie: {result.content}"
+ )
+ charlie_share_data = json.loads(result.content[0].text)
+ charlie_share_id = charlie_share_data["id"]
+ logger.info(f"Created share {charlie_share_id} for Charlie")
+
+ # Alice shares with Bob (read-only, permissions=1)
+ logger.info("Alice sharing file with Bob (read-only)...")
+ result = await alice_mcp_client.call_tool(
+ "nc_share_create",
+ arguments={
+ "path": file_path,
+ "share_with": "bob",
+ "share_type": 0,
+ "permissions": 1,
+ },
+ )
+ assert not result.isError, f"Alice failed to share with Bob: {result.content}"
+ bob_share_data = json.loads(result.content[0].text)
+ bob_share_id = bob_share_data["id"]
+ logger.info(f"Created share {bob_share_id} for Bob")
+
+ # Test: Charlie can write to the file
+ logger.info("Charlie attempting to write to file via MCP...")
+ updated_content = f"{file_content}\nCharlie added this line."
+ result = await charlie_mcp_client.call_tool(
+ "nc_webdav_write_file",
+ arguments={"path": file_path, "content": updated_content},
+ )
+
+ if not result.isError:
+ logger.info("Charlie successfully wrote to file")
+ else:
+ logger.warning(f"Charlie could not write to file: {result.content}")
+
+ # Test: Bob attempts to write (should fail)
+ logger.info("Bob attempting to write to file via MCP...")
+ result = await bob_mcp_client.call_tool(
+ "nc_webdav_write_file",
+ arguments={"path": file_path, "content": "Bob tries to overwrite this."},
+ )
+
+ # Bob should be denied
+ if result.isError:
+ logger.info("Bob correctly denied write access")
+ else:
+ logger.warning("Bob unexpectedly succeeded in writing (permissions issue?)")
+
+ finally:
+ # Cleanup - Alice deletes shares and file
+ if charlie_share_id:
+ logger.info(f"Alice deleting Charlie's share {charlie_share_id}")
+ await alice_mcp_client.call_tool(
+ "nc_share_delete", arguments={"share_id": charlie_share_id}
+ )
+ if bob_share_id:
+ logger.info(f"Alice deleting Bob's share {bob_share_id}")
+ await alice_mcp_client.call_tool(
+ "nc_share_delete", arguments={"share_id": bob_share_id}
+ )
+ logger.info(f"Alice deleting file {file_path}")
+ await alice_mcp_client.call_tool(
+ "nc_webdav_delete_resource", arguments={"path": file_path}
+ )
+
+
+@pytest.mark.anyio
+async def test_file_list_permissions(alice_mcp_client, bob_mcp_client):
+ """
+ Test that file listing respects share permissions.
+
+ Scenario:
+ 1. Alice creates her private file via MCP
+ 2. Bob creates his private file via MCP
+ 3. Alice creates a file and shares it with Bob via MCP
+ 4. Alice can list her own files + shared files
+ 5. Bob can list his own files + shared files from Alice
+ """
+ alice_file = "/alice_private_file.txt"
+ bob_file = "/bob_private_file.txt"
+ shared_file = "/alice_shared_with_bob.txt"
+
+ # Alice creates her private file
+ logger.info(f"Alice creating private file: {alice_file}")
+ result = await alice_mcp_client.call_tool(
+ "nc_webdav_write_file",
+ arguments={"path": alice_file, "content": "Alice's private file"},
+ )
+ assert not result.isError, f"Alice failed to create file: {result.content}"
+
+ # Bob creates his private file
+ logger.info(f"Bob creating private file: {bob_file}")
+ result = await bob_mcp_client.call_tool(
+ "nc_webdav_write_file",
+ arguments={"path": bob_file, "content": "Bob's private file"},
+ )
+ assert not result.isError, f"Bob failed to create file: {result.content}"
+
+ # Alice creates a shared file
+ logger.info(f"Alice creating shared file: {shared_file}")
+ result = await alice_mcp_client.call_tool(
+ "nc_webdav_write_file",
+ arguments={"path": shared_file, "content": "Shared file content"},
+ )
+ assert not result.isError, f"Alice failed to create shared file: {result.content}"
+
+ share_id = None
+
+ try:
+ # Alice shares the file with Bob
+ logger.info("Alice sharing file with Bob...")
+ result = await alice_mcp_client.call_tool(
+ "nc_share_create",
+ arguments={
+ "path": shared_file,
+ "share_with": "bob",
+ "share_type": 0,
+ "permissions": 1,
+ },
+ )
+ assert not result.isError, f"Alice failed to create share: {result.content}"
+ share_data = json.loads(result.content[0].text)
+ share_id = share_data["id"]
+
+ # Test: Alice lists files in root
+ logger.info("Alice listing files via MCP...")
+ result = await alice_mcp_client.call_tool(
+ "nc_webdav_list_directory", arguments={"path": "/"}
+ )
+
+ if not result.isError:
+ response_data = json.loads(result.content[0].text)
+ if not isinstance(response_data, list):
+ response_data = [response_data] if response_data else []
+ file_names = [f["name"] for f in response_data]
+ logger.info(f"Alice can see files: {file_names}")
+
+ # Alice should see her own files
+ # Note: Exact assertions depend on test isolation
+ else:
+ logger.warning(f"Alice could not list files: {result.content}")
+
+ # Test: Bob lists files in root
+ logger.info("Bob listing files via MCP...")
+ result = await bob_mcp_client.call_tool(
+ "nc_webdav_list_directory", arguments={"path": "/"}
+ )
+
+ if not result.isError:
+ response_data = json.loads(result.content[0].text)
+ if not isinstance(response_data, list):
+ response_data = [response_data] if response_data else []
+ file_names = [f["name"] for f in response_data]
+ logger.info(f"Bob can see files: {file_names}")
+
+ # Bob should see his own file, but not Alice's private file
+ # Bob may see shared files in his shared folder or via different path
+ else:
+ logger.warning(f"Bob could not list files: {result.content}")
+
+ finally:
+ # Cleanup
+ if share_id:
+ logger.info(f"Alice deleting share {share_id}")
+ await alice_mcp_client.call_tool(
+ "nc_share_delete", arguments={"share_id": share_id}
+ )
+
+ logger.info("Cleaning up Alice's files...")
+ await alice_mcp_client.call_tool(
+ "nc_webdav_delete_resource", arguments={"path": alice_file}
+ )
+ await alice_mcp_client.call_tool(
+ "nc_webdav_delete_resource", arguments={"path": shared_file}
+ )
+
+ logger.info("Cleaning up Bob's files...")
+ await bob_mcp_client.call_tool(
+ "nc_webdav_delete_resource", arguments={"path": bob_file}
+ )
+
+
+@pytest.mark.anyio
+async def test_folder_share_permissions(alice_mcp_client, bob_mcp_client):
+ """
+ Test that folder sharing works correctly.
+
+ Scenario:
+ 1. Alice creates a folder via MCP
+ 2. Alice creates files in the folder via MCP
+ 3. Alice shares the folder with Bob via MCP
+ 4. Bob can access files in the shared folder via MCP
+ """
+ folder_path = "/alice_shared_folder"
+ file_in_folder = f"{folder_path}/document.txt"
+ file_content = "This is a document in Alice's shared folder"
+
+ # Alice creates folder
+ logger.info(f"Alice creating folder: {folder_path}")
+ result = await alice_mcp_client.call_tool(
+ "nc_webdav_create_directory", arguments={"path": folder_path}
+ )
+ assert not result.isError, f"Alice failed to create folder: {result.content}"
+
+ # Alice creates file in folder
+ logger.info(f"Alice creating file in folder: {file_in_folder}")
+ result = await alice_mcp_client.call_tool(
+ "nc_webdav_write_file",
+ arguments={"path": file_in_folder, "content": file_content},
+ )
+ assert not result.isError, f"Alice failed to create file: {result.content}"
+
+ share_id = None
+
+ try:
+ # Alice shares the folder with Bob
+ logger.info("Alice sharing folder with Bob...")
+ result = await alice_mcp_client.call_tool(
+ "nc_share_create",
+ arguments={
+ "path": folder_path,
+ "share_with": "bob",
+ "share_type": 0,
+ "permissions": 1,
+ },
+ )
+ assert not result.isError, f"Alice failed to create share: {result.content}"
+ share_data = json.loads(result.content[0].text)
+ share_id = share_data["id"]
+ logger.info(f"Created folder share {share_id}")
+
+ # Test: Bob lists the shared folder
+ logger.info("Bob attempting to list shared folder via MCP...")
+ result = await bob_mcp_client.call_tool(
+ "nc_webdav_list_directory", arguments={"path": folder_path}
+ )
+
+ if not result.isError:
+ response_data = json.loads(result.content[0].text)
+ if not isinstance(response_data, list):
+ response_data = [response_data] if response_data else []
+ logger.info(f"Bob can see {len(response_data)} files in shared folder")
+
+ # Bob should see the file in the shared folder
+ file_names = [f["name"] for f in response_data]
+ assert "document.txt" in file_names, (
+ "Bob should see the file in shared folder"
+ )
+ else:
+ logger.warning(f"Bob could not list shared folder: {result.content}")
+
+ # Test: Bob reads the file in the shared folder
+ logger.info("Bob attempting to read file in shared folder via MCP...")
+ result = await bob_mcp_client.call_tool(
+ "nc_webdav_read_file", arguments={"path": file_in_folder}
+ )
+
+ if not result.isError:
+ response_data = json.loads(result.content[0].text)
+ logger.info("Bob successfully read file in shared folder")
+ assert "content" in response_data
+ assert file_content in response_data["content"]
+ else:
+ logger.warning(
+ f"Bob could not read file in shared folder: {result.content}"
+ )
+
+ finally:
+ # Cleanup - Alice deletes the share and folder
+ if share_id:
+ logger.info(f"Alice deleting share {share_id}")
+ await alice_mcp_client.call_tool(
+ "nc_share_delete", arguments={"share_id": share_id}
+ )
+
+ logger.info("Alice cleaning up test folder...")
+ await alice_mcp_client.call_tool(
+ "nc_webdav_delete_resource", arguments={"path": folder_path}
+ )
diff --git a/tests/server/test_oauth_notes_permissions.py b/tests/server/test_oauth_notes_permissions.py
new file mode 100644
index 0000000..d117e3a
--- /dev/null
+++ b/tests/server/test_oauth_notes_permissions.py
@@ -0,0 +1,260 @@
+"""
+Multi-user OAuth tests for Nextcloud Notes permissions.
+
+Tests verify that the MCP server respects Nextcloud Notes sharing permissions
+when accessed via OAuth authentication with different users.
+"""
+
+import json
+import logging
+
+import pytest
+
+logger = logging.getLogger(__name__)
+
+pytestmark = [pytest.mark.integration, pytest.mark.oauth]
+
+
+@pytest.mark.anyio
+async def test_notes_share_read_permissions(
+ nc_client, alice_mcp_client, bob_mcp_client, diana_mcp_client
+):
+ """
+ Test that shared notes respect read permissions.
+
+ Scenario:
+ 1. Admin creates a note as alice
+ 2. Admin shares the note with bob (read-only)
+ 3. Bob can read the note via MCP tools
+ 4. Diana cannot access the note (no share)
+ """
+ # Create a note as alice (using admin client to set up data)
+ note_title = "Alice's Shared Note - Read Test"
+ note_content = "This note is shared with Bob for reading only."
+ note_category = "SharedNotes"
+
+ logger.info("Creating note as alice...")
+ created_note = await nc_client.notes.create_note(
+ title=note_title, content=note_content, category=note_category
+ )
+ note_id = created_note.get("id")
+
+ try:
+ # TODO: Share the note with bob (read-only)
+ # Note: Nextcloud Notes API doesn't have direct sharing endpoints
+ # Sharing is typically done at the folder level via WebDAV
+ # For now, this test documents the expected behavior
+
+ # Test: Bob searches for notes via MCP
+ logger.info("Bob searching for notes via MCP...")
+ result = await bob_mcp_client.call_tool(
+ "nc_notes_search_notes", arguments={"query": "Alice's Shared"}
+ )
+
+ assert result.isError is False, f"Bob's search failed: {result.content}"
+ response_data = json.loads(result.content[0].text)
+
+ # Bob should see the shared note in search results
+ # (assuming proper share setup)
+ assert "results" in response_data
+ logger.info(f"Bob found {len(response_data['results'])} notes")
+
+ # Test: Diana searches for the same note
+ logger.info("Diana searching for notes via MCP...")
+ result = await diana_mcp_client.call_tool(
+ "nc_notes_search_notes", arguments={"query": "Alice's Shared"}
+ )
+
+ assert result.isError is False
+ response_data = json.loads(result.content[0].text)
+
+ # Diana should NOT see the note (no share)
+ assert "results" in response_data
+ shared_note_ids = [
+ n["id"] for n in response_data["results"] if n["id"] == note_id
+ ]
+ assert len(shared_note_ids) == 0, "Diana should not see unshared note"
+ logger.info("Diana correctly cannot see unshared note")
+
+ finally:
+ # Cleanup
+ logger.info(f"Cleaning up note {note_id}")
+ await nc_client.notes.delete_note(note_id)
+
+
+@pytest.mark.anyio
+async def test_notes_share_write_permissions(
+ nc_client, alice_mcp_client, charlie_mcp_client, bob_mcp_client
+):
+ """
+ Test that shared notes respect write permissions.
+
+ Scenario:
+ 1. Admin creates a note as alice
+ 2. Admin shares the note with charlie (edit permission)
+ 3. Admin shares the note with bob (read-only)
+ 4. Charlie can edit the note via MCP tools
+ 5. Bob cannot edit the note
+ """
+ # Create a note as alice
+ note_title = "Alice's Shared Note - Write Test"
+ note_content = "This note is shared with Charlie for editing."
+ note_category = "SharedNotes"
+
+ logger.info("Creating note as alice...")
+ created_note = await nc_client.notes.create_note(
+ title=note_title, content=note_content, category=note_category
+ )
+ note_id = created_note.get("id")
+
+ try:
+ # TODO: Share the note with charlie (edit permission) and bob (read-only)
+ # Note: Nextcloud Notes sharing is folder-based
+
+ # Test: Charlie can append content to the note
+ logger.info("Charlie attempting to append content via MCP...")
+ result = await charlie_mcp_client.call_tool(
+ "nc_notes_append_content",
+ arguments={
+ "note_id": note_id,
+ "content": "\n\nCharlie added this content.",
+ },
+ )
+
+ # If sharing is properly configured, Charlie should succeed
+ # Without proper sharing setup, this will fail
+ logger.info(f"Charlie's append result: isError={result.isError}")
+ if not result.isError:
+ logger.info("Charlie successfully appended content (shares configured)")
+ else:
+ logger.warning("Charlie could not append (shares not yet configured)")
+
+ # Test: Bob attempts to append content (should fail)
+ logger.info("Bob attempting to append content via MCP...")
+ result = await bob_mcp_client.call_tool(
+ "nc_notes_append_content",
+ arguments={"note_id": note_id, "content": "\n\nBob tried to add this."},
+ )
+
+ # Bob should fail (read-only access)
+ logger.info(f"Bob's append result: isError={result.isError}")
+ if result.isError:
+ logger.info("Bob correctly denied write access")
+ else:
+ logger.warning("Bob unexpectedly succeeded (permissions issue?)")
+
+ finally:
+ # Cleanup
+ logger.info(f"Cleaning up note {note_id}")
+ await nc_client.notes.delete_note(note_id)
+
+
+@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.
+
+ Scenario:
+ 1. Admin creates a note as alice (not shared)
+ 2. Admin creates a note as bob (not shared)
+ 3. Alice can only see her own note
+ 4. Bob can only see his own note
+ """
+ # Create alice's note
+ logger.info("Creating alice's private note...")
+ alice_note = await nc_client.notes.create_note(
+ title="Alice's Private Note",
+ content="This is Alice's private content.",
+ category="AlicePrivate",
+ )
+ alice_note_id = alice_note.get("id")
+
+ # Create bob's note
+ logger.info("Creating bob's private note...")
+ bob_note = await nc_client.notes.create_note(
+ title="Bob's Private Note",
+ content="This is Bob's private content.",
+ category="BobPrivate",
+ )
+ bob_note_id = bob_note.get("id")
+
+ try:
+ # Test: Alice searches all notes
+ logger.info("Alice searching all notes via MCP...")
+ result = await alice_mcp_client.call_tool(
+ "nc_notes_search_notes", arguments={"query": ""}
+ )
+
+ assert result.isError is False
+ response_data = json.loads(result.content[0].text)
+ alice_notes = response_data.get("results", [])
+ alice_note_ids = [n["id"] for n in alice_notes]
+
+ logger.info(f"Alice can see {len(alice_notes)} notes")
+ # Alice should NOT see Bob's note
+ assert bob_note_id not in alice_note_ids, (
+ "Alice should not see Bob's private note"
+ )
+
+ # Test: Bob searches all notes
+ logger.info("Bob searching all notes via MCP...")
+ result = await bob_mcp_client.call_tool(
+ "nc_notes_search_notes", arguments={"query": ""}
+ )
+
+ assert result.isError is False
+ response_data = json.loads(result.content[0].text)
+ bob_notes = response_data.get("results", [])
+ bob_note_ids = [n["id"] for n in bob_notes]
+
+ logger.info(f"Bob can see {len(bob_notes)} notes")
+ # Bob should NOT see Alice's note
+ assert alice_note_id not in bob_note_ids, (
+ "Bob should not see Alice's private note"
+ )
+
+ logger.info("User isolation test passed: users can only see their own notes")
+
+ finally:
+ # Cleanup
+ logger.info("Cleaning up test notes...")
+ await nc_client.notes.delete_note(alice_note_id)
+ await nc_client.notes.delete_note(bob_note_id)
+
+
+@pytest.mark.anyio
+async def test_oauth_mcp_clients_initialized(
+ alice_mcp_client, bob_mcp_client, charlie_mcp_client, diana_mcp_client
+):
+ """
+ Smoke test to verify all OAuth MCP clients are properly initialized.
+ """
+ logger.info("Testing alice_mcp_client initialization...")
+ result = await alice_mcp_client.call_tool(
+ "nc_notes_search_notes", arguments={"query": ""}
+ )
+ assert result.isError is False, f"Alice MCP client failed: {result.content}"
+ logger.info("Alice MCP client working")
+
+ logger.info("Testing bob_mcp_client initialization...")
+ result = await bob_mcp_client.call_tool(
+ "nc_notes_search_notes", arguments={"query": ""}
+ )
+ assert result.isError is False, f"Bob MCP client failed: {result.content}"
+ logger.info("Bob MCP client working")
+
+ logger.info("Testing charlie_mcp_client initialization...")
+ result = await charlie_mcp_client.call_tool(
+ "nc_notes_search_notes", arguments={"query": ""}
+ )
+ assert result.isError is False, f"Charlie MCP client failed: {result.content}"
+ logger.info("Charlie MCP client working")
+
+ logger.info("Testing diana_mcp_client initialization...")
+ result = await diana_mcp_client.call_tool(
+ "nc_notes_search_notes", arguments={"query": ""}
+ )
+ assert result.isError is False, f"Diana MCP client failed: {result.content}"
+ logger.info("Diana MCP client working")
+
+ logger.info("All OAuth MCP clients successfully initialized!")
diff --git a/tests/server/test_users_api.py b/tests/server/test_users_api.py
new file mode 100644
index 0000000..ed8d4d8
--- /dev/null
+++ b/tests/server/test_users_api.py
@@ -0,0 +1,109 @@
+import pytest
+
+from nextcloud_mcp_server.client import NextcloudClient
+
+
+@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
+
+ # Create user
+ await nc_client.users.create_user(**user_config)
+
+ # Verify user exists
+ users = await nc_client.users.search_users(search=user_config["userid"])
+ assert user_config["userid"] in users
+
+ user_details = await nc_client.users.get_user_details(user_config["userid"])
+ assert user_details.id == user_config["userid"]
+ assert user_details.displayname == user_config["display_name"]
+ assert user_details.email == user_config["email"]
+
+ # Test deletion explicitly as part of test functionality
+ await nc_client.users.delete_user(user_config["userid"])
+
+ # Verify user is deleted
+ users = await nc_client.users.search_users(search=user_config["userid"])
+ assert user_config["userid"] not in users
+ # Note: Fixture cleanup will also try to delete but handle 404 gracefully
+
+
+@pytest.mark.anyio
+async def test_update_user_field(nc_client: NextcloudClient, test_user):
+ """Test updating user fields."""
+ user_config = test_user
+
+ await nc_client.users.create_user(**user_config)
+
+ new_email = f"new.{user_config['email']}"
+ await nc_client.users.update_user_field(user_config["userid"], "email", new_email)
+
+ user_details = await nc_client.users.get_user_details(user_config["userid"])
+ assert user_details.email == new_email
+ # Fixture will handle cleanup
+
+
+@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
+ userid = user_config["userid"]
+
+ # Verify user is in group
+ groups = await nc_client.users.get_user_groups(userid)
+ assert groupid in groups
+
+ # Remove user from group
+ await nc_client.users.remove_user_from_group(userid, groupid)
+ groups = await nc_client.users.get_user_groups(userid)
+ assert groupid not in groups
+ # Fixtures will handle cleanup
+
+
+@pytest.mark.anyio
+async def test_user_subadmins(nc_client: NextcloudClient, test_user, test_group):
+ """Test promoting and demoting subadmins."""
+ user_config = test_user
+ groupid = test_group
+ userid = user_config["userid"]
+
+ await nc_client.users.create_user(**user_config)
+
+ # Promote to subadmin
+ await nc_client.users.promote_user_to_subadmin(userid, groupid)
+ subadmin_groups = await nc_client.users.get_user_subadmin_groups(userid)
+ assert groupid in subadmin_groups
+
+ # Demote from subadmin
+ await nc_client.users.demote_user_from_subadmin(userid, groupid)
+ subadmin_groups = await nc_client.users.get_user_subadmin_groups(userid)
+ assert groupid not in subadmin_groups
+ # Fixtures will handle cleanup
+
+
+@pytest.mark.anyio
+async def test_disable_enable_user(nc_client: NextcloudClient, test_user):
+ """Test disabling and enabling users."""
+ user_config = test_user
+ userid = user_config["userid"]
+
+ await nc_client.users.create_user(**user_config)
+
+ # Disable user
+ await nc_client.users.disable_user(userid)
+ user_details = await nc_client.users.get_user_details(userid)
+ assert not user_details.enabled
+
+ # Enable user
+ await nc_client.users.enable_user(userid)
+ user_details = await nc_client.users.get_user_details(userid)
+ assert user_details.enabled
+ # Fixture will handle cleanup
+
+
+@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
+ assert "email" in editable_fields
diff --git a/tests/server/test_webdav_search_mcp.py b/tests/server/test_webdav_search_mcp.py
new file mode 100644
index 0000000..25f0900
--- /dev/null
+++ b/tests/server/test_webdav_search_mcp.py
@@ -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}"
diff --git a/tests/test_models.py b/tests/test_models.py
index 0f7bf0d..2157617 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -1,9 +1,9 @@
"""Unit tests for Pydantic models and serialization."""
-from datetime import datetime, timezone
import json
import logging
import re
+from datetime import datetime, timezone
from nextcloud_mcp_server.models.base import BaseResponse
diff --git a/uv.lock b/uv.lock
index 00ce5ab..4c56b53 100644
--- a/uv.lock
+++ b/uv.lock
@@ -55,73 +55,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 }
+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 },
+ { 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 }
+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 },
+ { 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 }
+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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { url = "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", size = 99616 },
- { url = "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", size = 107108 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822 },
- { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580 },
- { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086 },
- { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400 },
- { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175 },
+ { 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]]
@@ -170,89 +201,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 }
+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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { url = "https://files.pythonhosted.org/packages/2f/88/a23cc185f6a805dfc4fdf14a94016835eeb85e22ac3a0e66d5e89acd6462/coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14", size = 220536 },
- { url = "https://files.pythonhosted.org/packages/fe/ef/0b510a399dfca17cec7bc2f05ad8bd78cf55f15c8bc9a73ab20c5c913c2e/coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2", size = 221425 },
- { url = "https://files.pythonhosted.org/packages/51/7f/023657f301a276e4ba1850f82749bc136f5a7e8768060c2e5d9744a22951/coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a", size = 220103 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { url = "https://files.pythonhosted.org/packages/5e/75/61b9bbd6c7d24d896bfeec57acba78e0f8deac68e6baf2d4804f7aae1f88/coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256", size = 220699 },
- { url = "https://files.pythonhosted.org/packages/ca/f3/3bf7905288b45b075918d372498f1cf845b5b579b723c8fd17168018d5f5/coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba", size = 221512 },
- { url = "https://files.pythonhosted.org/packages/5c/44/3e32dbe933979d05cf2dac5e697c8599cfe038aaf51223ab901e208d5a62/coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf", size = 220147 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746 },
- { url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541 },
- { url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403 },
- { url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469 },
- { url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009 },
- { url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804 },
- { url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784 },
- { url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905 },
- { url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922 },
- { url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952 },
+ { 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]
@@ -299,6 +330,48 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317 },
]
+[[package]]
+name = "greenlet"
+version = "3.2.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a4/de/f28ced0a67749cac23fecb02b694f6473f47686dff6afaa211d186e2ef9c/greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2", size = 272305, upload-time = "2025-08-07T13:15:41.288Z" },
+ { url = "https://files.pythonhosted.org/packages/09/16/2c3792cba130000bf2a31c5272999113f4764fd9d874fb257ff588ac779a/greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246", size = 632472, upload-time = "2025-08-07T13:42:55.044Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/8f/95d48d7e3d433e6dae5b1682e4292242a53f22df82e6d3dda81b1701a960/greenlet-3.2.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94abf90142c2a18151632371140b3dba4dee031633fe614cb592dbb6c9e17bc3", size = 644646, upload-time = "2025-08-07T13:45:26.523Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/5e/405965351aef8c76b8ef7ad370e5da58d57ef6068df197548b015464001a/greenlet-3.2.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:4d1378601b85e2e5171b99be8d2dc85f594c79967599328f95c1dc1a40f1c633", size = 640519, upload-time = "2025-08-07T13:53:13.928Z" },
+ { url = "https://files.pythonhosted.org/packages/25/5d/382753b52006ce0218297ec1b628e048c4e64b155379331f25a7316eb749/greenlet-3.2.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0db5594dce18db94f7d1650d7489909b57afde4c580806b8d9203b6e79cdc079", size = 639707, upload-time = "2025-08-07T13:18:27.146Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/8e/abdd3f14d735b2929290a018ecf133c901be4874b858dd1c604b9319f064/greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8", size = 587684, upload-time = "2025-08-07T13:18:25.164Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/65/deb2a69c3e5996439b0176f6651e0052542bb6c8f8ec2e3fba97c9768805/greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52", size = 1116647, upload-time = "2025-08-07T13:42:38.655Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/cc/b07000438a29ac5cfb2194bfc128151d52f333cee74dd7dfe3fb733fc16c/greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa", size = 1142073, upload-time = "2025-08-07T13:18:21.737Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/0f/30aef242fcab550b0b3520b8e3561156857c94288f0332a79928c31a52cf/greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9", size = 299100, upload-time = "2025-08-07T13:44:12.287Z" },
+ { url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" },
+ { url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/16/035dcfcc48715ccd345f3a93183267167cdd162ad123cd93067d86f27ce4/greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968", size = 655185, upload-time = "2025-08-07T13:45:27.624Z" },
+ { url = "https://files.pythonhosted.org/packages/31/da/0386695eef69ffae1ad726881571dfe28b41970173947e7c558d9998de0f/greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9", size = 649926, upload-time = "2025-08-07T13:53:15.251Z" },
+ { url = "https://files.pythonhosted.org/packages/68/88/69bf19fd4dc19981928ceacbc5fd4bb6bc2215d53199e367832e98d1d8fe/greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6", size = 651839, upload-time = "2025-08-07T13:18:30.281Z" },
+ { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" },
+ { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" },
+ { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" },
+ { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" },
+ { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" },
+ { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" },
+]
+
[[package]]
name = "h11"
version = "0.16.0"
@@ -308,6 +381,28 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 },
]
+[[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"
@@ -336,13 +431,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 },
]
+[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 }
+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 },
+ { 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]]
@@ -360,25 +469,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 }
+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 },
+ { 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 }
+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 },
+ { 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 = "(platform_machine != 'aarch64' and platform_system == 'Linux' and sys_platform == 'win32') or (platform_system != 'Darwin' and platform_system != 'Linux' and sys_platform == 'win32')" },
@@ -393,9 +502,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 }
+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 },
+ { 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]]
@@ -461,6 +570,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 },
]
+[[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"
@@ -561,7 +772,7 @@ wheels = [
[[package]]
name = "mcp"
-version = "1.16.0"
+version = "1.18.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -576,9 +787,9 @@ dependencies = [
{ name = "starlette" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/3d/a1/b1f328da3b153683d2ec34f849b4b6eac2790fb240e3aef06ff2fab3df9d/mcp-1.16.0.tar.gz", hash = "sha256:39b8ca25460c578ee2cdad33feeea122694cfdf73eef58bee76c42f6ef0589df", size = 472918 }
+sdist = { url = "https://files.pythonhosted.org/packages/1a/e0/fe34ce16ea2bacce489ab859abd1b47ae28b438c3ef60b9c5eee6c02592f/mcp-1.18.0.tar.gz", hash = "sha256:aa278c44b1efc0a297f53b68df865b988e52dd08182d702019edcf33a8e109f6", size = 482926, upload-time = "2025-10-16T19:19:55.125Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c9/0e/7cebc88e17daf94ebe28c95633af595ccb2864dc2ee7abd75542d98495cc/mcp-1.16.0-py3-none-any.whl", hash = "sha256:ec917be9a5d31b09ba331e1768aa576e0af45470d657a0319996a20a57d7d633", size = 167266 },
+ { url = "https://files.pythonhosted.org/packages/1b/44/f5970e3e899803823826283a70b6003afd46f28e082544407e24575eccd3/mcp-1.18.0-py3-none-any.whl", hash = "sha256:42f10c270de18e7892fdf9da259029120b1ea23964ff688248c69db9d72b1d0a", size = 168762, upload-time = "2025-10-16T19:19:53.2Z" },
]
[package.optional-dependencies]
@@ -598,9 +809,10 @@ wheels = [
[[package]]
name = "nextcloud-mcp-server"
-version = "0.12.5"
+version = "0.17.1"
source = { editable = "." }
dependencies = [
+ { name = "caldav" },
{ name = "click" },
{ name = "httpx" },
{ name = "icalendar" },
@@ -614,20 +826,22 @@ dependencies = [
dev = [
{ name = "commitizen" },
{ name = "ipython" },
+ { name = "playwright" },
{ name = "pytest" },
- { name = "pytest-asyncio" },
{ name = "pytest-cov" },
- { name = "reportlab" },
+ { 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" },
- { name = "mcp", extras = ["cli"], specifier = ">=1.16,<1.17" },
- { name = "pillow", specifier = ">=11.2.1,<12.0.0" },
+ { name = "mcp", extras = ["cli"], specifier = ">=1.18,<1.19" },
+ { name = "pillow", specifier = ">=12.0.0,<12.1.0" },
{ name = "pydantic", specifier = ">=2.11.4" },
{ name = "pythonvcard4", specifier = ">=0.2.0" },
]
@@ -636,10 +850,11 @@ requires-dist = [
dev = [
{ name = "commitizen", specifier = ">=4.8.2" },
{ 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 = "reportlab", specifier = ">=4.0.0" },
+ { name = "pytest-playwright-asyncio", specifier = ">=0.7.1" },
+ { name = "pytest-timeout", specifier = ">=2.3.1" },
{ name = "ruff", specifier = ">=0.11.13" },
]
@@ -675,86 +890,108 @@ wheels = [
[[package]]
name = "pillow"
-version = "11.3.0"
+version = "12.0.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069 }
+sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/db/26/77f8ed17ca4ffd60e1dcd220a6ec6d71210ba398cfa33a13a1cd614c5613/pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722", size = 5316531 },
- { url = "https://files.pythonhosted.org/packages/cb/39/ee475903197ce709322a17a866892efb560f57900d9af2e55f86db51b0a5/pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288", size = 4686560 },
- { url = "https://files.pythonhosted.org/packages/d5/90/442068a160fd179938ba55ec8c97050a612426fae5ec0a764e345839f76d/pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d", size = 5870978 },
- { url = "https://files.pythonhosted.org/packages/13/92/dcdd147ab02daf405387f0218dcf792dc6dd5b14d2573d40b4caeef01059/pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494", size = 7641168 },
- { url = "https://files.pythonhosted.org/packages/6e/db/839d6ba7fd38b51af641aa904e2960e7a5644d60ec754c046b7d2aee00e5/pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58", size = 5973053 },
- { url = "https://files.pythonhosted.org/packages/f2/2f/d7675ecae6c43e9f12aa8d58b6012683b20b6edfbdac7abcb4e6af7a3784/pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f", size = 6640273 },
- { url = "https://files.pythonhosted.org/packages/45/ad/931694675ede172e15b2ff03c8144a0ddaea1d87adb72bb07655eaffb654/pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e", size = 6082043 },
- { url = "https://files.pythonhosted.org/packages/3a/04/ba8f2b11fc80d2dd462d7abec16351b45ec99cbbaea4387648a44190351a/pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94", size = 6715516 },
- { url = "https://files.pythonhosted.org/packages/48/59/8cd06d7f3944cc7d892e8533c56b0acb68399f640786313275faec1e3b6f/pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0", size = 6274768 },
- { url = "https://files.pythonhosted.org/packages/f1/cc/29c0f5d64ab8eae20f3232da8f8571660aa0ab4b8f1331da5c2f5f9a938e/pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac", size = 6986055 },
- { url = "https://files.pythonhosted.org/packages/c6/df/90bd886fabd544c25addd63e5ca6932c86f2b701d5da6c7839387a076b4a/pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd", size = 2423079 },
- { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800 },
- { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296 },
- { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726 },
- { url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652 },
- { url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787 },
- { url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236 },
- { url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950 },
- { url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358 },
- { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079 },
- { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324 },
- { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067 },
- { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328 },
- { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652 },
- { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443 },
- { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474 },
- { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038 },
- { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407 },
- { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094 },
- { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503 },
- { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574 },
- { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060 },
- { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407 },
- { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841 },
- { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450 },
- { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055 },
- { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110 },
- { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547 },
- { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554 },
- { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132 },
- { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001 },
- { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814 },
- { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124 },
- { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186 },
- { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546 },
- { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102 },
- { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803 },
- { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520 },
- { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116 },
- { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597 },
- { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246 },
- { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336 },
- { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699 },
- { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789 },
- { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386 },
- { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911 },
- { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383 },
- { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385 },
- { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129 },
- { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580 },
- { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860 },
- { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694 },
- { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888 },
- { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330 },
- { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089 },
- { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206 },
- { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370 },
- { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500 },
- { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835 },
- { url = "https://files.pythonhosted.org/packages/9e/e3/6fa84033758276fb31da12e5fb66ad747ae83b93c67af17f8c6ff4cc8f34/pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6", size = 5270566 },
- { url = "https://files.pythonhosted.org/packages/5b/ee/e8d2e1ab4892970b561e1ba96cbd59c0d28cf66737fc44abb2aec3795a4e/pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438", size = 4654618 },
- { url = "https://files.pythonhosted.org/packages/f2/6d/17f80f4e1f0761f02160fc433abd4109fa1548dcfdca46cfdadaf9efa565/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3", size = 4874248 },
- { url = "https://files.pythonhosted.org/packages/de/5f/c22340acd61cef960130585bbe2120e2fd8434c214802f07e8c03596b17e/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c", size = 6583963 },
- { url = "https://files.pythonhosted.org/packages/31/5e/03966aedfbfcbb4d5f8aa042452d3361f325b963ebbadddac05b122e47dd/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361", size = 4957170 },
- { url = "https://files.pythonhosted.org/packages/cc/2d/e082982aacc927fc2cab48e1e731bdb1643a1406acace8bed0900a61464e/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7", size = 5581505 },
- { url = "https://files.pythonhosted.org/packages/34/e7/ae39f538fd6844e982063c3a5e4598b8ced43b9633baa3a85ef33af8c05c/pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8", size = 6984598 },
+ { url = "https://files.pythonhosted.org/packages/0e/5a/a2f6773b64edb921a756eb0729068acad9fc5208a53f4a349396e9436721/pillow-12.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc", size = 5289798, upload-time = "2025-10-15T18:21:47.763Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/05/069b1f8a2e4b5a37493da6c5868531c3f77b85e716ad7a590ef87d58730d/pillow-12.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257", size = 4650589, upload-time = "2025-10-15T18:21:49.515Z" },
+ { url = "https://files.pythonhosted.org/packages/61/e3/2c820d6e9a36432503ead175ae294f96861b07600a7156154a086ba7111a/pillow-12.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642", size = 6230472, upload-time = "2025-10-15T18:21:51.052Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/89/63427f51c64209c5e23d4d52071c8d0f21024d3a8a487737caaf614a5795/pillow-12.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3", size = 8033887, upload-time = "2025-10-15T18:21:52.604Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/1b/c9711318d4901093c15840f268ad649459cd81984c9ec9887756cca049a5/pillow-12.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c", size = 6343964, upload-time = "2025-10-15T18:21:54.619Z" },
+ { url = "https://files.pythonhosted.org/packages/41/1e/db9470f2d030b4995083044cd8738cdd1bf773106819f6d8ba12597d5352/pillow-12.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227", size = 7034756, upload-time = "2025-10-15T18:21:56.151Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/b0/6177a8bdd5ee4ed87cba2de5a3cc1db55ffbbec6176784ce5bb75aa96798/pillow-12.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b", size = 6458075, upload-time = "2025-10-15T18:21:57.759Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/5e/61537aa6fa977922c6a03253a0e727e6e4a72381a80d63ad8eec350684f2/pillow-12.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e", size = 7125955, upload-time = "2025-10-15T18:21:59.372Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/3d/d5033539344ee3cbd9a4d69e12e63ca3a44a739eb2d4c8da350a3d38edd7/pillow-12.0.0-cp311-cp311-win32.whl", hash = "sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739", size = 6298440, upload-time = "2025-10-15T18:22:00.982Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/42/aaca386de5cc8bd8a0254516957c1f265e3521c91515b16e286c662854c4/pillow-12.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e", size = 6999256, upload-time = "2025-10-15T18:22:02.617Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/f1/9197c9c2d5708b785f631a6dfbfa8eb3fb9672837cb92ae9af812c13b4ed/pillow-12.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d", size = 2436025, upload-time = "2025-10-15T18:22:04.598Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/90/4fcce2c22caf044e660a198d740e7fbc14395619e3cb1abad12192c0826c/pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", size = 5249377, upload-time = "2025-10-15T18:22:05.993Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/e0/ed960067543d080691d47d6938ebccbf3976a931c9567ab2fbfab983a5dd/pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", size = 4650343, upload-time = "2025-10-15T18:22:07.718Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/a1/f81fdeddcb99c044bf7d6faa47e12850f13cee0849537a7d27eeab5534d4/pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", size = 6232981, upload-time = "2025-10-15T18:22:09.287Z" },
+ { url = "https://files.pythonhosted.org/packages/88/e1/9098d3ce341a8750b55b0e00c03f1630d6178f38ac191c81c97a3b047b44/pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d", size = 8041399, upload-time = "2025-10-15T18:22:10.872Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/62/a22e8d3b602ae8cc01446d0c57a54e982737f44b6f2e1e019a925143771d/pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953", size = 6347740, upload-time = "2025-10-15T18:22:12.769Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8", size = 7040201, upload-time = "2025-10-15T18:22:14.813Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/4d/435c8ac688c54d11755aedfdd9f29c9eeddf68d150fe42d1d3dbd2365149/pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79", size = 6462334, upload-time = "2025-10-15T18:22:16.375Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/f2/ad34167a8059a59b8ad10bc5c72d4d9b35acc6b7c0877af8ac885b5f2044/pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba", size = 7134162, upload-time = "2025-10-15T18:22:17.996Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/b1/a7391df6adacf0a5c2cf6ac1cf1fcc1369e7d439d28f637a847f8803beb3/pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", size = 6298769, upload-time = "2025-10-15T18:22:19.923Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/0b/d87733741526541c909bbf159e338dcace4f982daac6e5a8d6be225ca32d/pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", size = 7001107, upload-time = "2025-10-15T18:22:21.644Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/96/aaa61ce33cc98421fb6088af2a03be4157b1e7e0e87087c888e2370a7f45/pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", size = 2436012, upload-time = "2025-10-15T18:22:23.621Z" },
+ { url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493, upload-time = "2025-10-15T18:22:25.758Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461, upload-time = "2025-10-15T18:22:27.286Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912, upload-time = "2025-10-15T18:22:28.751Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132, upload-time = "2025-10-15T18:22:30.641Z" },
+ { url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099, upload-time = "2025-10-15T18:22:32.73Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808, upload-time = "2025-10-15T18:22:34.337Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804, upload-time = "2025-10-15T18:22:36.402Z" },
+ { url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553, upload-time = "2025-10-15T18:22:38.066Z" },
+ { url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729, upload-time = "2025-10-15T18:22:39.769Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789, upload-time = "2025-10-15T18:22:41.437Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917, upload-time = "2025-10-15T18:22:43.152Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391, upload-time = "2025-10-15T18:22:44.753Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477, upload-time = "2025-10-15T18:22:46.838Z" },
+ { url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918, upload-time = "2025-10-15T18:22:48.399Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406, upload-time = "2025-10-15T18:22:49.905Z" },
+ { url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218, upload-time = "2025-10-15T18:22:51.587Z" },
+ { url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564, upload-time = "2025-10-15T18:22:53.215Z" },
+ { url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260, upload-time = "2025-10-15T18:22:54.933Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248, upload-time = "2025-10-15T18:22:56.605Z" },
+ { url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043, upload-time = "2025-10-15T18:22:58.53Z" },
+ { url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915, upload-time = "2025-10-15T18:23:00.582Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998, upload-time = "2025-10-15T18:23:02.627Z" },
+ { url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201, upload-time = "2025-10-15T18:23:04.709Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165, upload-time = "2025-10-15T18:23:06.46Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834, upload-time = "2025-10-15T18:23:08.194Z" },
+ { url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" },
+ { url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" },
+ { url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" },
+ { url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" },
+ { url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" },
+ { url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" },
+ { url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" },
+ { url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" },
+ { url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" },
+ { url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/b3/582327e6c9f86d037b63beebe981425d6811104cb443e8193824ef1a2f27/pillow-12.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8", size = 5215068, upload-time = "2025-10-15T18:23:59.594Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/d6/67748211d119f3b6540baf90f92fae73ae51d5217b171b0e8b5f7e5d558f/pillow-12.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a", size = 4614994, upload-time = "2025-10-15T18:24:01.669Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/e1/f8281e5d844c41872b273b9f2c34a4bf64ca08905668c8ae730eedc7c9fa/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197", size = 5246639, upload-time = "2025-10-15T18:24:03.403Z" },
+ { url = "https://files.pythonhosted.org/packages/94/5a/0d8ab8ffe8a102ff5df60d0de5af309015163bf710c7bb3e8311dd3b3ad0/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c", size = 6986839, upload-time = "2025-10-15T18:24:05.344Z" },
+ { url = "https://files.pythonhosted.org/packages/20/2e/3434380e8110b76cd9eb00a363c484b050f949b4bbe84ba770bb8508a02c/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e", size = 5313505, upload-time = "2025-10-15T18:24:07.137Z" },
+ { url = "https://files.pythonhosted.org/packages/57/ca/5a9d38900d9d74785141d6580950fe705de68af735ff6e727cb911b64740/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76", size = 5963654, upload-time = "2025-10-15T18:24:09.579Z" },
+ { url = "https://files.pythonhosted.org/packages/95/7e/f896623c3c635a90537ac093c6a618ebe1a90d87206e42309cb5d98a1b9e/pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5", size = 6997850, upload-time = "2025-10-15T18:24:11.495Z" },
+]
+
+[[package]]
+name = "playwright"
+version = "1.55.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "greenlet" },
+ { name = "pyee" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/80/3a/c81ff76df266c62e24f19718df9c168f49af93cabdbc4608ae29656a9986/playwright-1.55.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:d7da108a95001e412effca4f7610de79da1637ccdf670b1ae3fdc08b9694c034", size = 40428109, upload-time = "2025-08-28T15:46:20.357Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/f5/bdb61553b20e907196a38d864602a9b4a461660c3a111c67a35179b636fa/playwright-1.55.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8290cf27a5d542e2682ac274da423941f879d07b001f6575a5a3a257b1d4ba1c", size = 38687254, upload-time = "2025-08-28T15:46:23.925Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/64/48b2837ef396487807e5ab53c76465747e34c7143fac4a084ef349c293a8/playwright-1.55.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:25b0d6b3fd991c315cca33c802cf617d52980108ab8431e3e1d37b5de755c10e", size = 40428108, upload-time = "2025-08-28T15:46:27.119Z" },
+ { url = "https://files.pythonhosted.org/packages/08/33/858312628aa16a6de97839adc2ca28031ebc5391f96b6fb8fdf1fcb15d6c/playwright-1.55.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c6d4d8f6f8c66c483b0835569c7f0caa03230820af8e500c181c93509c92d831", size = 45905643, upload-time = "2025-08-28T15:46:30.312Z" },
+ { url = "https://files.pythonhosted.org/packages/83/83/b8d06a5b5721931aa6d5916b83168e28bd891f38ff56fe92af7bdee9860f/playwright-1.55.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29a0777c4ce1273acf90c87e4ae2fe0130182100d99bcd2ae5bf486093044838", size = 45296647, upload-time = "2025-08-28T15:46:33.221Z" },
+ { url = "https://files.pythonhosted.org/packages/06/2e/9db64518aebcb3d6ef6cd6d4d01da741aff912c3f0314dadb61226c6a96a/playwright-1.55.0-py3-none-win32.whl", hash = "sha256:29e6d1558ad9d5b5c19cbec0a72f6a2e35e6353cd9f262e22148685b86759f90", size = 35476046, upload-time = "2025-08-28T15:46:36.184Z" },
+ { url = "https://files.pythonhosted.org/packages/46/4f/9ba607fa94bb9cee3d4beb1c7b32c16efbfc9d69d5037fa85d10cafc618b/playwright-1.55.0-py3-none-win_amd64.whl", hash = "sha256:7eb5956473ca1951abb51537e6a0da55257bb2e25fc37c2b75af094a5c93736c", size = 35476048, upload-time = "2025-08-28T15:46:38.867Z" },
+ { url = "https://files.pythonhosted.org/packages/21/98/5ca173c8ec906abde26c28e1ecb34887343fd71cc4136261b90036841323/playwright-1.55.0-py3-none-win_arm64.whl", hash = "sha256:012dc89ccdcbd774cdde8aeee14c08e0dd52ddb9135bf10e9db040527386bd76", size = 31225543, upload-time = "2025-08-28T15:46:41.613Z" },
]
[[package]]
@@ -798,7 +1035,7 @@ wheels = [
[[package]]
name = "pydantic"
-version = "2.11.9"
+version = "2.12.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
@@ -806,74 +1043,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 }
+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 },
+ { 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 }
+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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538 },
- { url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909 },
- { url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628 },
- { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866 },
- { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473 },
- { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269 },
- { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921 },
- { 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 },
- { 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 },
- { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
+ { 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]]
@@ -890,6 +1155,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/83/d6/887a1ff844e64aa823fb4905978d882a633cfe295c32eacad582b78a7d8b/pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c", size = 48608 },
]
+[[package]]
+name = "pyee"
+version = "13.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/95/03/1fd98d5841cd7964a27d729ccf2199602fe05eb7a405c1462eb7277945ed/pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37", size = 31250, upload-time = "2025-03-17T18:53:15.955Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9b/4d/b9add7c84060d4c1906abe9a7e5359f2a60f7a9a4f67268b2766673427d8/pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498", size = 15730, upload-time = "2025-03-17T18:53:14.532Z" },
+]
+
[[package]]
name = "pygments"
version = "2.19.2"
@@ -928,6 +1205,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095 },
]
+[[package]]
+name = "pytest-base-url"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ae/1a/b64ac368de6b993135cb70ca4e5d958a5c268094a3a2a4cac6f0021b6c4f/pytest_base_url-2.1.0.tar.gz", hash = "sha256:02748589a54f9e63fcbe62301d6b0496da0d10231b753e950c63e03aee745d45", size = 6702, upload-time = "2024-01-31T22:43:00.81Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/98/1c/b00940ab9eb8ede7897443b771987f2f4a76f06be02f1b3f01eb7567e24a/pytest_base_url-2.1.0-py3-none-any.whl", hash = "sha256:3ad15611778764d451927b2a53240c1a7a591b521ea44cebfe45849d2d2812e6", size = 5302, upload-time = "2024-01-31T22:42:58.897Z" },
+]
+
[[package]]
name = "pytest-cov"
version = "7.0.0"
@@ -942,6 +1232,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424 },
]
+[[package]]
+name = "pytest-playwright-asyncio"
+version = "0.7.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "playwright" },
+ { name = "pytest" },
+ { name = "pytest-asyncio" },
+ { name = "pytest-base-url" },
+ { name = "python-slugify" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f8/14/bdabbbcceea6acdcab21d5e920671ce27268d505d1800228c61b14fc0a47/pytest_playwright_asyncio-0.7.1.tar.gz", hash = "sha256:696896e27d8d6b0029f9d324d9b1ae64cfb239c378c13440ea06af4df68ccfae", size = 16836, upload-time = "2025-09-08T08:10:54.877Z" }
+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"
@@ -972,6 +1290,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546 },
]
+[[package]]
+name = "python-slugify"
+version = "8.0.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "text-unidecode" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921, upload-time = "2024-02-08T18:32:45.488Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" },
+]
+
[[package]]
name = "pythonvcard4"
version = "0.2.0"
@@ -1067,44 +1397,61 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753 },
]
+[[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 }
+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 },
+ { 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]]
-name = "reportlab"
-version = "4.4.4"
+name = "requests"
+version = "2.32.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
+ { name = "certifi" },
{ name = "charset-normalizer" },
- { name = "pillow" },
+ { name = "idna" },
+ { name = "urllib3" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/f8/fa/ed71f3e750afb77497641eb0194aeda069e271ce6d6931140f8787e0e69a/reportlab-4.4.4.tar.gz", hash = "sha256:cb2f658b7f4a15be2cc68f7203aa67faef67213edd4f2d4bdd3eb20dab75a80d", size = 3711935 }
+sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/57/66/e040586fe6f9ae7f3a6986186653791fb865947f0b745290ee4ab026b834/reportlab-4.4.4-py3-none-any.whl", hash = "sha256:299b3b0534e7202bb94ed2ddcd7179b818dcda7de9d8518a57c85a58a1ebaadb", size = 1954981 },
+ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
[[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 }
+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 },
+ { 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]]
@@ -1217,28 +1564,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 }
+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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { url = "https://files.pythonhosted.org/packages/f7/00/e7f1501e81e8ec290e79527827af1d88f541d8d26151751b46108978dade/ruff-0.13.2-py3-none-win32.whl", hash = "sha256:7c2a0b7c1e87795fec3404a485096bcd790216c7c146a922d121d8b9c8f1aaac", size = 12245990 },
- { url = "https://files.pythonhosted.org/packages/ee/bd/d9f33a73de84fafd0146c6fba4f497c4565fe8fa8b46874b8e438869abc2/ruff-0.13.2-py3-none-win_amd64.whl", hash = "sha256:17d95fb32218357c89355f6f6f9a804133e404fc1f65694372e02a557edf8585", size = 13324004 },
- { url = "https://files.pythonhosted.org/packages/c3/12/28fa2f597a605884deb0f65c1b1ae05111051b2a7030f5d8a4ff7f4599ba/ruff-0.13.2-py3-none-win_arm64.whl", hash = "sha256:da711b14c530412c827219312b7d7fbb4877fb31150083add7e8c5336549cea7", size = 12484437 },
+ { 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]]
@@ -1317,42 +1664,61 @@ wheels = [
]
[[package]]
-name = "tomli"
-version = "2.2.1"
+name = "text-unidecode"
+version = "1.3"
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 }
+sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885, upload-time = "2019-08-30T21:36:45.405Z" }
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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 },
- { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 },
- { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 },
- { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 },
- { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 },
+ { url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" },
+]
+
+[[package]]
+name = "tomli"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+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/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]]
@@ -1399,14 +1765,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 }
+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 },
+ { 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]]
@@ -1418,17 +1784,26 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839 },
]
+[[package]]
+name = "urllib3"
+version = "2.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
+]
+
[[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 }
+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 },
+ { 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]]
@@ -1498,3 +1873,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 },
{ url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591 },
]
+
+[[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" },
+]