diff --git a/LLMS.txt b/LLMS.txt new file mode 100644 index 0000000..35ef2c5 --- /dev/null +++ b/LLMS.txt @@ -0,0 +1,235 @@ +# python-icap + +> Pure Python ICAP client library with no external dependencies + +python-icap is a Python library for communicating with ICAP (Internet Content Adaptation Protocol) servers. It implements RFC 3507 for integrating with servers like c-icap and SquidClamav for antivirus scanning, content filtering, and data loss prevention. + +## Key Features + +- Pure Python implementation with zero runtime dependencies +- Sync (`IcapClient`) and async (`AsyncIcapClient`) clients with full API parity +- High-level file scanning: `scan_file()`, `scan_bytes()`, `scan_stream()` +- SSL/TLS support with custom certificates and mutual TLS +- Bundled pytest plugin for testing without a live server +- Python 3.8+ support + +## Installation + +```bash +pip install python-icap +``` + +## Quick Start + +```python +from icap import IcapClient + +with IcapClient('localhost', port=1344) as client: + response = client.scan_file('/path/to/file.pdf') + if response.is_no_modification: + print("File is clean") + else: + print("Threat detected") +``` + +Async usage: + +```python +import asyncio +from icap import AsyncIcapClient + +async def scan(): + async with AsyncIcapClient('localhost') as client: + response = await client.scan_bytes(b"content") + print(f"Clean: {response.is_no_modification}") + +asyncio.run(scan()) +``` + +## Public API + +### Main Classes + +Import from `icap`: + +- `IcapClient(address, port=1344, timeout=10, ssl_context=None, max_response_size=104857600)` - Synchronous client +- `AsyncIcapClient(address, port=1344, timeout=10.0, ssl_context=None, max_response_size=104857600)` - Async client (max_response_size limits response size to prevent DoS, default 100MB) +- `IcapResponse` - Response object with `status_code`, `headers`, `body`, `is_success`, `is_no_modification`, `encapsulated`, `istag` +- `EncapsulatedParts` - Dataclass for parsed Encapsulated header offsets (`req_hdr`, `req_body`, `res_hdr`, `res_body`, `null_body`, `opt_body`) +- `CaseInsensitiveDict` - Case-insensitive dictionary for ICAP headers (per RFC 3507) + +### High-Level Methods (Recommended) + +- `scan_file(filepath, service='avscan')` - Scan file by path +- `scan_bytes(data, service='avscan', filename=None)` - Scan bytes directly +- `scan_stream(stream, service='avscan', filename=None, chunk_size=0)` - Scan file-like objects + +### Low-Level Methods + +- `options(service)` - Query server capabilities +- `respmod(service, http_request, http_response, headers=None, preview=None)` - Response modification +- `reqmod(service, http_request, http_body=None, headers=None)` - Request modification + +### IcapResponse Properties + +- `status_code` - ICAP status code (200, 204, etc.) +- `headers` - Case-insensitive header dictionary +- `body` - Response body bytes +- `is_success` - True for 2xx status codes +- `is_no_modification` - True for 204 (content is clean) +- `encapsulated` - Parsed `EncapsulatedParts` from Encapsulated header (or None) +- `istag` - ISTag header value for cache validation (RFC 3507 Section 4.7) + +### Connection Management + +- `connect()` / `disconnect()` - Manual connection control +- `is_connected` - Property to check connection status +- Context manager support: `with IcapClient(...) as client:` + +### Exceptions + +Import from `icap.exception`: + +- `IcapException` - Base exception class +- `IcapConnectionError` - Connection failures +- `IcapTimeoutError` - Operation timeouts +- `IcapProtocolError` - Malformed responses +- `IcapServerError` - Server 5xx errors + +## Pytest Plugin + +The bundled pytest plugin provides fixtures for testing ICAP integrations. + +### Mock Fixtures (no server required) + +- `mock_icap_client` - Returns clean (204) responses +- `mock_async_icap_client` - Async version +- `mock_icap_client_virus` - Returns virus detection responses +- `mock_icap_client_timeout` - Raises `IcapTimeoutError` +- `mock_icap_client_connection_error` - Raises `IcapConnectionError` + +### Response Builder + +```python +from icap.pytest_plugin import IcapResponseBuilder + +response = IcapResponseBuilder().clean().build() # 204 No Modification +response = IcapResponseBuilder().virus("Trojan.Gen").build() # Virus detected +response = IcapResponseBuilder().error(503, "Unavailable").build() # Error +``` + +### Pre-built Response Fixtures + +- `icap_response_clean` - 204 No Modification response +- `icap_response_virus` - Virus detection response (X-Virus-ID: EICAR-Test) +- `icap_response_options` - OPTIONS response with server capabilities +- `icap_response_error` - 500 Server Error response +- `icap_response_builder` - Factory fixture returning `IcapResponseBuilder` + +### MockIcapClient + +```python +from icap.pytest_plugin import MockIcapClient, IcapResponseBuilder + +client = MockIcapClient() +client.on_respmod(IcapResponseBuilder().virus("Trojan").build()) +response = client.scan_bytes(b"content") +client.assert_called("scan_bytes", times=1) +``` + +### Markers + +```python +@pytest.mark.icap_mock(response="clean") +def test_clean(icap_mock): + assert icap_mock.scan_bytes(b"data").is_no_modification + +@pytest.mark.icap_mock(response="virus", virus_name="EICAR") +def test_virus(icap_mock): + assert not icap_mock.scan_bytes(b"data").is_no_modification +``` + +### Advanced Mock Features + +```python +from icap.pytest_plugin import MockIcapClient, IcapResponseBuilder, MatcherBuilder + +# Conditional responses based on content +client = MockIcapClient() +client.when( + MatcherBuilder().body_contains(b"EICAR").build() +).respond(IcapResponseBuilder().virus("EICAR-Test").build()) +client.default_response(IcapResponseBuilder().clean().build()) + +# Call inspection +response = client.scan_bytes(b"test") +call = client.last_call +assert call.method == "scan_bytes" +assert call.args[0] == b"test" + +# Strict mode (fails if no matching response) +@pytest.mark.icap_mock(strict=True) +def test_strict(icap_mock): + icap_mock.on_respmod(IcapResponseBuilder().clean().build()) + # MockResponseExhaustedError if called more times than configured +``` + +## Project Structure + +``` +src/icap/ +├── __init__.py # Public API exports +├── icap.py # Synchronous IcapClient +├── async_icap.py # AsyncIcapClient +├── response.py # IcapResponse, EncapsulatedParts, CaseInsensitiveDict +├── exception.py # Custom exceptions +├── _protocol.py # Shared protocol utilities +└── pytest_plugin/ # Bundled pytest plugin + ├── plugin.py # Fixtures and markers + ├── builder.py # IcapResponseBuilder + ├── mock_client.py # MockIcapClient (sync) + ├── mock_async.py # MockAsyncIcapClient + ├── matchers.py # ResponseMatcher, MatcherBuilder + ├── call_record.py # MockCall for call inspection + ├── protocols.py # ResponseCallback protocols + └── mock.py # Re-exports for backward compatibility +``` + +## Common Service Names + +- `"avscan"` or `"srv_clamav"` - ClamAV virus scanning +- `"squidclamav"` - SquidClamav service +- `"echo"` - Echo service (testing) + +## Testing + +```bash +# Unit tests +pytest -m "not integration" + +# Integration tests (requires Docker) +docker compose -f docker/docker-compose.yml up -d +pytest -m integration +``` + +## EICAR Test String + +Standard test string for triggering antivirus detection: + +```python +EICAR = b'X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' +``` + +## Protocol Reference + +- RFC 3507: Internet Content Adaptation Protocol +- Default port: 1344 +- Methods: OPTIONS, REQMOD, RESPMOD + +## Links + +- Repository: https://github.com/CaptainDriftwood/python-icap +- PyPI: https://pypi.org/project/python-icap/ +- c-icap: https://c-icap.sourceforge.net/ +- SquidClamav: https://squidclamav.darold.net/ +- ClamAV: https://www.clamav.net/ \ No newline at end of file diff --git a/README.md b/README.md index 7ad240e..1242fff 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ [![Python 3.8 | 3.9 | 3.10 | 3.11 | 3.12 | 3.13 | 3.14](https://img.shields.io/badge/python-3.8%20%7C%203.9%20%7C%203.10%20%7C%203.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-blue.svg)](https://www.python.org/downloads/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![No Dependencies](https://img.shields.io/badge/dependencies-none-brightgreen.svg)](pyproject.toml) +[![LLMS.txt](https://img.shields.io/badge/LLMS-txt-blue)](https://github.com/CaptainDriftwood/python-icap/blob/master/LLMS.txt) [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv) [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) @@ -21,6 +22,7 @@ A pure Python ICAP (Internet Content Adaptation Protocol) client with no externa ## Table of Contents +- [Quick Reference](#quick-reference) - [Overview](#overview) - [What is ICAP?](#what-is-icap) - [Key Differences from HTTP](#key-differences-from-http) @@ -39,6 +41,7 @@ A pure Python ICAP (Internet Content Adaptation Protocol) client with no externa - [Scanning Content with RESPMOD](#scanning-content-with-respmod) - [Scanning Files](#scanning-files) - [Manual File Scanning (lower-level API)](#manual-file-scanning-lower-level-api) + - [Preview Mode](#preview-mode) - [Async Usage](#async-usage) - [Basic Async Example](#basic-async-example) - [Concurrent Scanning](#concurrent-scanning) @@ -75,6 +78,39 @@ python-icap provides a clean, Pythonic API for integrating ICAP into your applic - **Pytest plugin** - Mock clients and fixtures for testing without a live server - **Zero dependencies** - Pure Python stdlib implementation +## Quick Reference + +```python +# Common imports +from icap import IcapClient, AsyncIcapClient, IcapResponse +from icap.exception import IcapException, IcapConnectionError, IcapTimeoutError + +# Scan bytes (simplest) +with IcapClient("localhost") as client: + response = client.scan_bytes(b"content") + is_clean = response.is_no_modification + +# Scan file +with IcapClient("localhost") as client: + response = client.scan_file("/path/to/file.pdf") + +# Async scan +async with AsyncIcapClient("localhost") as client: + response = await client.scan_bytes(b"content") + +# Check server capabilities +with IcapClient("localhost") as client: + response = client.options("avscan") + preview_size = response.headers.get("Preview") # bytes for preview mode + +# Response properties +response.status_code # 200, 204, etc. +response.is_success # True for 2xx +response.is_no_modification # True for 204 (clean) +response.headers # Dict of ICAP headers +response.body # Response body bytes +``` + ## What is ICAP? **ICAP (Internet Content Adaptation Protocol)** is a simple protocol that lets network devices (like proxies) send HTTP content to a separate server for inspection or modification before passing it along. @@ -188,10 +224,11 @@ This allows the ICAP server to efficiently parse the message without scanning th ## Installation -> **Note:** This package is not yet published to PyPI due to a name collision. Install directly from source. - ```bash -# Standard installation +# Install from PyPI +pip install python-icap + +# Or install from source pip install . # Development installation (editable) @@ -322,6 +359,51 @@ else: print("File contains threats") ``` +### Preview Mode + +ICAP servers can advertise a preview size via OPTIONS, allowing clients to send just the beginning of a file for initial scanning. If the server can determine the content is clean from the preview alone, it returns 204; otherwise it requests the full content with 100 Continue. + +```python +from icap import IcapClient + +with IcapClient('localhost', port=1344) as client: + # Step 1: Query server capabilities to get preview size + options_response = client.options('avscan') + preview_size = options_response.headers.get('Preview') + + if preview_size: + print(f"Server supports preview mode: {preview_size} bytes") + else: + print("Server does not advertise preview support") + + # Step 2: Send RESPMOD with preview enabled + # The client handles the 100 Continue flow automatically + content = b"Large file content here..." * 1000 + + response = client.scan_bytes( + content, + service='avscan', + preview=int(preview_size) if preview_size else None + ) + + if response.is_no_modification: + print("Content is clean") + else: + print("Threat detected") +``` + +**How Preview Mode Works:** + +1. Client sends OPTIONS to discover the server's preview size +2. Client sends RESPMOD with only the first N bytes (preview) +3. Server analyzes the preview: + - Returns **204 No Modification** if the preview is enough to determine content is clean + - Returns **100 Continue** to request the remaining data +4. If 100 Continue received, client sends the rest of the content +5. Server returns final verdict (200 or 204) + +Preview mode reduces bandwidth and latency when scanning large files that are obviously clean (or obviously malicious) from their headers. + ## Async Usage python-icap includes an async client (`AsyncIcapClient`) for use with `asyncio`. The async client provides the same API as the sync client but with `async`/`await` syntax. @@ -596,8 +678,8 @@ python-icap/ │ ├── async_icap.py # Asynchronous ICAP client │ ├── _protocol.py # Shared protocol constants │ ├── response.py # Response handling -│ └── exception.py # Custom exceptions -├── pytest_src/icap/ # Pytest plugin for ICAP testing +│ ├── exception.py # Custom exceptions +│ └── pytest_plugin/ # Bundled pytest plugin for testing ├── tests/ # Unit tests ├── examples/ # Usage examples ├── docker/ # Docker setup for integration testing diff --git a/examples/basic_example.py b/examples/basic_example.py index ba276ef..87da298 100644 --- a/examples/basic_example.py +++ b/examples/basic_example.py @@ -1,6 +1,13 @@ #!/usr/bin/env python3 """ Basic example of using the python-icap ICAP client. + +This example demonstrates the recommended high-level API methods: +- scan_bytes(): Scan in-memory content +- scan_file(): Scan a file from disk +- options(): Query server capabilities + +For advanced use cases, see the low-level respmod() and reqmod() methods. """ from test_utils import EICAR_TEST_STRING @@ -18,56 +25,71 @@ def main(): print("python-icap ICAP Client - Basic Example") print("=" * 60) - # Example 1: Test OPTIONS method - print("\n1. Testing OPTIONS method...") + # Example 1: Query server capabilities with OPTIONS + print("\n1. Querying server capabilities...") try: with IcapClient(ICAP_HOST, ICAP_PORT) as client: response = client.options(SERVICE) print(f" Status: {response.status_code} {response.status_message}") - print(f" Headers: {response.headers}") + print(f" Methods: {response.headers.get('Methods', 'N/A')}") + print(f" Preview: {response.headers.get('Preview', 'N/A')} bytes") except Exception as e: print(f" Error: {e}") - # Example 2: Scan clean content - print("\n2. Scanning clean content with RESPMOD...") + # Example 2: Scan clean content using scan_bytes() (recommended) + print("\n2. Scanning clean content...") try: - http_request = b"GET / HTTP/1.1\r\nHost: www.example.com\r\n\r\n" - http_response = ( - b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 13\r\n\r\nHello, World!" - ) + clean_content = b"Hello, World! This is clean content." with IcapClient(ICAP_HOST, ICAP_PORT) as client: - response = client.respmod(SERVICE, http_request, http_response) + response = client.scan_bytes(clean_content, service=SERVICE) print(f" Status: {response.status_code} {response.status_message}") if response.is_no_modification: print(" Result: Content is CLEAN (204 No Modification)") - elif response.is_success: - print(" Result: Content was modified/blocked") else: - print(" Result: Error occurred") + print(" Result: Content was modified or flagged") except Exception as e: print(f" Error: {e}") # Example 3: Scan EICAR test virus print("\n3. Scanning EICAR test virus...") try: - http_request = b"GET /test.txt HTTP/1.1\r\nHost: www.example.com\r\n\r\n" - http_response = ( - f"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {len(EICAR_TEST_STRING)}\r\n\r\n".encode() - + EICAR_TEST_STRING - ) - with IcapClient(ICAP_HOST, ICAP_PORT) as client: - response = client.respmod(SERVICE, http_request, http_response) + response = client.scan_bytes(EICAR_TEST_STRING, service=SERVICE) print(f" Status: {response.status_code} {response.status_message}") if response.is_no_modification: - print(" Result: Content passed (204 No Modification)") - elif response.status_code == 200: - print(" Result: VIRUS DETECTED - Content blocked/modified") + print(" Result: Content passed (unexpected for EICAR)") else: - print(" Result: Unexpected response") + virus_id = response.headers.get("X-Virus-ID", "Unknown threat") + print(f" Result: THREAT DETECTED - {virus_id}") + except Exception as e: + print(f" Error: {e}") + + # Example 4: Scan a file (if you have one) + print("\n4. Scanning a file...") + try: + import os + import tempfile + + # Create a temporary file for demonstration + with tempfile.NamedTemporaryFile(mode="wb", suffix=".txt", delete=False) as f: + f.write(b"This is a test file with clean content.") + temp_path = f.name + + try: + with IcapClient(ICAP_HOST, ICAP_PORT) as client: + response = client.scan_file(temp_path, service=SERVICE) + print(f" File: {temp_path}") + print(f" Status: {response.status_code} {response.status_message}") + + if response.is_no_modification: + print(" Result: File is CLEAN") + else: + print(" Result: File was flagged") + finally: + os.unlink(temp_path) # Clean up temp file except Exception as e: print(f" Error: {e}") diff --git a/justfile b/justfile index 825356e..54254bf 100644 --- a/justfile +++ b/justfile @@ -9,9 +9,9 @@ install: # Run all checks (lint, typecheck, test) check: lint typecheck test -# Run unit tests +# Run unit tests (parallel by default) test *args: - uv run pytest -m "not integration" {{ args }} + uv run pytest -m "not integration and not benchmark" -n auto {{ args }} # Run integration tests (requires Docker) test-integration *args: @@ -161,5 +161,16 @@ clean: build: uv build +# Run performance benchmarks (requires Docker) +benchmark *args: + #!/usr/bin/env bash + set -euo pipefail + echo "Starting ICAP server..." + docker compose -f docker/docker-compose.yml up -d + echo "Waiting for services to initialize..." + sleep 30 + trap "echo 'Stopping ICAP server...'; docker compose -f docker/docker-compose.yml down" EXIT + uv run pytest -m "benchmark" --benchmark-only {{ args }} + # Run a full CI-like check ci: fmt-check lint typecheck test diff --git a/pyproject.toml b/pyproject.toml index 1d79582..003efc6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,7 @@ markers = [ "docker: marks tests requiring Docker (deselect with '-m \"not docker\"')", "ssl: marks tests requiring SSL/TLS certificates (run: just generate-certs)", "slow: marks tests as slow (deselect with '-m \"not slow\"')", + "benchmark: marks performance benchmark tests (run: just benchmark)", "icap(host, port, timeout, ssl_context): configure ICAP client for testing", "icap_mock(response, virus_name, raises, options, respmod, reqmod): configure mock ICAP client", ] @@ -70,6 +71,8 @@ filterwarnings = [ "ignore:The @wait_container_is_ready decorator is deprecated:DeprecationWarning", # Ignore pytest-asyncio warning in pytester subprocess tests (our main config has it set) "ignore:The configuration option \"asyncio_default_fixture_loop_scope\" is unset:pytest.PytestDeprecationWarning", + # Ignore pytest-benchmark warning when running with xdist (benchmarks are excluded from parallel runs) + "ignore:Benchmarks are automatically disabled because xdist plugin is active:pytest_benchmark.logger.PytestBenchmarkWarning", ] [tool.ruff] @@ -100,16 +103,19 @@ indent-style = "space" [dependency-groups] dev = [ "coverage[toml]>=7.0.0", + "hypothesis>=6.0.0", "nox>=2024.0.0", "nox-uv>=0.2.0; python_version >= '3.9'", "pytest>=7.0.0", "pytest-asyncio>=0.21.0", + "pytest-benchmark>=4.0.0", "pytest-cov>=4.0.0", "pytest-mock>=3.10.0", "pytest-timeout>=2.0.0", + "pytest-xdist>=3.0.0", "ruff>=0.1.0", - "setuptools>=75.3.2", # Required for PyCharm's test runner - "testcontainers>=3.7.0", + "setuptools>=78.1.1; python_version >= '3.9'", # Required for PyCharm's test runner; >=78.1.1 fixes GHSA-5rjg-fvgr-3xxf + "testcontainers>=4.0.0; python_version >= '3.9'", "ty>=0.0.8", ] @@ -118,22 +124,67 @@ source = ["icap"] branch = true parallel = true omit = [ - # Import-time only files - class/import definitions execute before coverage starts - # due to pytest plugin loading order + # Import-time only files - executed before coverage starts "*/icap/__init__.py", "*/icap/exception.py", "*/icap/pytest_plugin/plugin.py", + # Base protocol class - only tested via subclasses in integration tests + "*/icap/_protocol.py", ] [tool.coverage.report] +# Show missing line numbers in terminal output +show_missing = true + +# Don't clutter report with fully-covered files +skip_covered = true + +# Don't show files with no executable statements +skip_empty = true + exclude_lines = [ + # Standard pragmas "pragma: no cover", + + # Type checking imports (never executed at runtime) "if TYPE_CHECKING:", + + # Main script guard "if __name__ == .__main__.:", + + # Abstract method markers "raise NotImplementedError", - # Exclude docstrings (use literal strings for regex backslashes) + "@abstractmethod", + + # Ellipsis (used in stubs/protocols) + "^\\s*\\.\\.\\.$", + + # Pass statements (empty implementations) + "^\\s*pass\\s*$", + + # Simple property getters/setters returning private attributes + "^\\s*return self\\._\\w+\\s*$", + + # Defensive assertions that shouldn't trigger + "raise AssertionError", + + # Docstring delimiters '^\\s*"""', "^\\s*'''", '^\\s*r"""', "^\\s*r'''", + + # Docstring section headers (Google/NumPy style) + "^\\s*Args:\\s*$", + "^\\s*Returns:\\s*$", + "^\\s*Raises:\\s*$", + "^\\s*Example:\\s*$", + "^\\s*Examples:\\s*$", + "^\\s*Attributes:\\s*$", + "^\\s*Note:\\s*$", + "^\\s*See Also:\\s*$", + + # Doctest examples in docstrings + "^\\s*>>>", + "^\\s*\\.\\.\\.", ] diff --git a/src/icap/__init__.py b/src/icap/__init__.py index 0140c63..5253b10 100644 --- a/src/icap/__init__.py +++ b/src/icap/__init__.py @@ -10,7 +10,7 @@ IcapTimeoutError, ) from .icap import IcapClient -from .response import IcapResponse +from .response import CaseInsensitiveDict, EncapsulatedParts, IcapResponse # Set up logging with NullHandler to avoid "No handler found" warnings logging.getLogger(__name__).addHandler(logging.NullHandler()) @@ -19,6 +19,8 @@ __all__ = [ "AsyncIcapClient", + "CaseInsensitiveDict", + "EncapsulatedParts", "IcapClient", "IcapResponse", "IcapException", diff --git a/src/icap/_protocol.py b/src/icap/_protocol.py index 839d318..676a7b8 100644 --- a/src/icap/_protocol.py +++ b/src/icap/_protocol.py @@ -4,7 +4,21 @@ shared between the sync IcapClient and async AsyncIcapClient. """ -from typing import Dict, Optional +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Callable + +from .exception import IcapProtocolError + +# Characters that are invalid in header names (per RFC 7230) +# Header names must be tokens: 1*tchar where tchar excludes CTLs, separators +_INVALID_HEADER_NAME_CHARS = re.compile(r"[\x00-\x1f\x7f()<>@,;:\\\"/\[\]?={} \t]") + +# Characters that are invalid in header values (control chars except HTAB) +# CRLF injection is the main concern - values must not contain CR or LF +_INVALID_HEADER_VALUE_CHARS = re.compile(r"[\x00-\x08\x0a-\x1f\x7f]") class IcapProtocol: @@ -16,7 +30,33 @@ class IcapProtocol: BUFFER_SIZE: int = 8192 USER_AGENT: str = "Python-ICAP-Client/1.0" - def _build_request(self, request_line: str, headers: Dict[str, str]) -> bytes: + @staticmethod + def _validate_header(name: str, value: str) -> None: + """Validate header name and value to prevent injection attacks. + + Args: + name: Header name + value: Header value + + Raises: + ValueError: If header name or value contains invalid characters + """ + if not name: + raise ValueError("Header name cannot be empty") + + if _INVALID_HEADER_NAME_CHARS.search(name): + raise ValueError( + f"Invalid header name {name!r}: contains invalid characters " + "(control characters, spaces, or separators not allowed)" + ) + + if _INVALID_HEADER_VALUE_CHARS.search(value): + raise ValueError( + f"Invalid header value for {name!r}: contains control characters " + "(CR, LF, and other control characters not allowed)" + ) + + def _build_request(self, request_line: str, headers: dict[str, str]) -> bytes: """Build ICAP request from request line and headers. Args: @@ -25,14 +65,18 @@ def _build_request(self, request_line: str, headers: Dict[str, str]) -> bytes: Returns: Encoded request bytes + + Raises: + ValueError: If any header name or value contains invalid characters """ request = request_line for key, value in headers.items(): + self._validate_header(key, value) request += f"{key}: {value}{self.CRLF}" request += self.CRLF return request.encode("utf-8") - def _build_http_request_header(self, filename: Optional[str]) -> bytes: + def _build_http_request_header(self, filename: str | None) -> bytes: """Build encapsulated HTTP request header for file scanning. Args: @@ -96,3 +140,186 @@ def _encode_chunk_terminator() -> bytes: Zero-length chunk terminator bytes """ return b"0\r\n\r\n" + + +# ============================================================================= +# Shared Protocol Utilities +# ============================================================================= +# These functions contain pure protocol logic shared between sync and async +# clients. They perform no I/O operations. + + +@dataclass +class ResponseHeaders: + """Parsed response header information.""" + + content_length: int | None + """Content-Length header value, or None if not present.""" + + is_chunked: bool + """True if Transfer-Encoding: chunked is present.""" + + +@dataclass +class PreviewData: + """Data prepared for preview mode transmission.""" + + preview_chunk: bytes + """Encoded preview chunk including terminator (with ieof if complete).""" + + remainder: bytes + """Remaining body data to send after 100 Continue.""" + + is_complete: bool + """True if entire body fits in preview (no remainder needed).""" + + +def parse_response_headers(headers_str: str) -> ResponseHeaders: + """Parse response headers to extract Content-Length and Transfer-Encoding. + + This function extracts the information needed to determine how to read + the response body: by Content-Length, by chunked encoding, or no body. + + Args: + headers_str: Raw headers string (decoded from bytes, excluding the + terminating CRLF CRLF) + + Returns: + ResponseHeaders with content_length and is_chunked fields. + + Raises: + IcapProtocolError: If Content-Length header has an invalid value. + """ + content_length: int | None = None + is_chunked = False + + for line in headers_str.split("\r\n")[1:]: # Skip status line + if ":" in line: + key, value = line.split(":", 1) + key_lower = key.strip().lower() + value_stripped = value.strip().lower() + + if key_lower == "content-length": + try: + content_length = int(value.strip()) + except ValueError: + raise IcapProtocolError( + f"Invalid Content-Length header: {value.strip()!r}" + ) from None + if content_length < 0: + raise IcapProtocolError( + f"Invalid Content-Length header: {content_length} (must be non-negative)" + ) + elif key_lower == "transfer-encoding" and "chunked" in value_stripped: + is_chunked = True + + return ResponseHeaders(content_length=content_length, is_chunked=is_chunked) + + +def parse_chunk_size(size_line: bytes, max_size: int) -> int: + """Parse and validate a chunk size line from chunked transfer encoding. + + Per RFC 7230, chunk size is a hex number optionally followed by extensions + after a semicolon. This function parses the size and validates it against + the maximum allowed size. + + Args: + size_line: Raw chunk size line (without trailing CRLF) + max_size: Maximum allowed chunk size in bytes + + Returns: + The parsed chunk size as an integer. + + Raises: + IcapProtocolError: If the chunk size is invalid or exceeds max_size. + """ + try: + # Chunk size may have extensions after semicolon, ignore them + chunk_size = int(size_line.split(b";")[0].strip(), 16) + except ValueError: + raise IcapProtocolError(f"Invalid chunk size in response: {size_line!r}") from None + + if chunk_size < 0: + raise IcapProtocolError(f"Invalid chunk size in response: {size_line!r}") + + if chunk_size > max_size: + raise IcapProtocolError( + f"Chunk size ({chunk_size:,} bytes) exceeds maximum allowed size ({max_size:,} bytes)" + ) + + return chunk_size + + +def validate_body_size(current_size: int, max_size: int) -> None: + """Validate that body size doesn't exceed maximum allowed. + + Args: + current_size: Current accumulated body size in bytes + max_size: Maximum allowed size in bytes + + Raises: + IcapProtocolError: If current_size exceeds max_size. + """ + if current_size > max_size: + raise IcapProtocolError( + f"Chunked response body ({current_size:,} bytes) exceeds " + f"maximum allowed size ({max_size:,} bytes)" + ) + + +def validate_content_length(content_length: int, max_size: int) -> None: + """Validate Content-Length against maximum allowed size. + + Args: + content_length: Declared content length in bytes + max_size: Maximum allowed size in bytes + + Raises: + IcapProtocolError: If content_length exceeds max_size. + """ + if content_length > max_size: + raise IcapProtocolError( + f"Response Content-Length ({content_length:,} bytes) exceeds " + f"maximum allowed size ({max_size:,} bytes)" + ) + + +def prepare_preview_data( + body: bytes, + preview_size: int, + encode_chunked: Callable[[bytes], bytes], + encode_terminator: Callable[[], bytes], +) -> PreviewData: + """Prepare body data for preview mode transmission. + + Splits the body into preview and remainder portions, and encodes the + preview chunk with appropriate terminator (ieof if complete). + + Per RFC 3507 Section 4.5, if the entire body fits within the preview, + the zero-length chunk terminator should include the "ieof" extension + to indicate no more data follows. + + Args: + body: Full body content to be sent + preview_size: Maximum bytes to include in preview + encode_chunked: Function to encode data as a chunk (IcapProtocol._encode_chunked) + encode_terminator: Function to get terminator (IcapProtocol._encode_chunk_terminator) + + Returns: + PreviewData with encoded preview_chunk, remainder, and is_complete flag. + """ + preview_data = body[:preview_size] + remainder = body[preview_size:] + is_complete = len(body) <= preview_size + + # Build the preview chunk + preview_chunk = encode_chunked(preview_data) + + if is_complete: + # Use ieof on zero-length chunk to indicate no more data + preview_chunk += b"0; ieof\r\n\r\n" + else: + # Normal zero-length terminator for preview section + preview_chunk += encode_terminator() + + return PreviewData(preview_chunk=preview_chunk, remainder=remainder, is_complete=is_complete) diff --git a/src/icap/async_icap.py b/src/icap/async_icap.py index 44e9b3d..f03a2e4 100644 --- a/src/icap/async_icap.py +++ b/src/icap/async_icap.py @@ -4,9 +4,16 @@ import logging import ssl from pathlib import Path -from typing import Any, BinaryIO, Dict, Optional, Union - -from ._protocol import IcapProtocol +from typing import Any, AsyncIterator, BinaryIO, Dict, Optional, Union + +from ._protocol import ( + IcapProtocol, + parse_chunk_size, + parse_response_headers, + prepare_preview_data, + validate_body_size, + validate_content_length, +) from .exception import IcapConnectionError, IcapProtocolError, IcapServerError, IcapTimeoutError from .response import IcapResponse @@ -79,12 +86,19 @@ class AsyncIcapClient(IcapProtocol): - IcapResponse: Response object returned by all methods """ + # Default maximum response size (100MB) + DEFAULT_MAX_RESPONSE_SIZE: int = 104_857_600 + + # Maximum header section size (64KB) - prevents DoS from endless headers + MAX_HEADER_SIZE: int = 65536 + def __init__( self, address: str, port: int = IcapProtocol.DEFAULT_PORT, timeout: float = 10.0, ssl_context: Optional[ssl.SSLContext] = None, + max_response_size: int = DEFAULT_MAX_RESPONSE_SIZE, ) -> None: """ Initialize async ICAP client. @@ -92,11 +106,17 @@ def __init__( Args: address: ICAP server hostname or IP address port: ICAP server port (default: 1344) - timeout: Operation timeout in seconds (default: 10.0) + timeout: Operation timeout in seconds (default: 10.0). Accepts float + for sub-second precision (e.g., 0.5 for 500ms). Note: the sync + IcapClient uses int for timeout due to socket.settimeout() semantics. ssl_context: Optional SSL context for TLS connections. If provided, the connection will be wrapped with SSL/TLS. You can create a context using ssl.create_default_context() for standard TLS, or customize it for specific certificate requirements. + max_response_size: Maximum allowed response size in bytes (default: 100MB). + This limits both Content-Length values and individual chunk sizes + in chunked transfer encoding. Increase this if you need to scan + files larger than 100MB. Must be a positive integer. Example: >>> # Standard TLS with system CA certificates @@ -106,11 +126,17 @@ def __init__( >>> # TLS with custom CA certificate >>> ssl_ctx = ssl.create_default_context(cafile='/path/to/ca.pem') >>> client = AsyncIcapClient('icap.example.com', ssl_context=ssl_ctx) + + >>> # Scanning large files (up to 500MB) + >>> client = AsyncIcapClient('localhost', max_response_size=500_000_000) """ + if max_response_size <= 0: + raise ValueError("max_response_size must be a positive integer") self._address: str = address self._port: int = port self._timeout: float = timeout self._ssl_context: Optional[ssl.SSLContext] = ssl_context + self._max_response_size: int = max_response_size self._reader: Optional[asyncio.StreamReader] = None self._writer: Optional[asyncio.StreamWriter] = None logger.debug( @@ -194,13 +220,29 @@ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> bool: async def options(self, service: str) -> IcapResponse: """ - Send OPTIONS request to ICAP server. + Send OPTIONS request to query ICAP server capabilities. + + The OPTIONS request retrieves information about the ICAP service, + including supported methods, preview size, and transfer encodings. Args: service: ICAP service name (e.g., "avscan") Returns: - IcapResponse object + IcapResponse with headers containing server capabilities: + - Methods: Supported ICAP methods (e.g., "RESPMOD, REQMOD") + - Preview: Suggested preview size in bytes for this service + - Transfer-Preview: File extensions that benefit from preview + - Max-Connections: Maximum concurrent connections allowed + - Options-TTL: How long (seconds) to cache this OPTIONS response + - Service-ID: Unique identifier for this service instance + + Example: + >>> async with AsyncIcapClient('localhost') as client: + ... response = await client.options("avscan") + ... preview_size = int(response.headers.get("Preview", 0)) + ... methods = response.headers.get("Methods", "") + ... print(f"Preview: {preview_size}, Methods: {methods}") """ if self._writer is None: await self.connect() @@ -413,9 +455,12 @@ async def scan_stream( stream: File-like object (must support read()) service: ICAP service name (default: "avscan") filename: Optional filename to use in HTTP headers - chunk_size: If > 0, stream data in chunks of this size (bytes). - This uses chunked transfer encoding to avoid loading - the entire file into memory. + chunk_size: Controls memory usage for large files. + - 0 (default): Reads entire stream into memory before sending. + Simple but may exhaust memory for very large files. + - >0: Uses chunked streaming, reading and sending in chunks of + this size (bytes). Set to 65536 for 64KB chunks, 1048576 for 1MB. + Recommended for files larger than available memory. Returns: IcapResponse object @@ -548,20 +593,15 @@ async def _scan_stream_chunked( logger.debug(f"Sent {total_bytes} bytes in chunked encoding") # Receive response - response_data = await self._receive_response() + response = await self._receive_response() except asyncio.TimeoutError: raise IcapTimeoutError(f"Request to {self.host}:{self.port} timed out") from None except OSError as e: - self._connected = False + self._writer = None + self._reader = None raise IcapConnectionError(f"Connection error with {self.host}:{self.port}: {e}") from e - # Parse response - try: - response = IcapResponse.parse(response_data) - except ValueError as e: - raise IcapProtocolError(f"Failed to parse ICAP response: {e}") from e - # Check for server errors if 500 <= response.status_code < 600: raise IcapServerError( @@ -570,7 +610,7 @@ async def _scan_stream_chunked( return response - async def _iter_chunks(self, stream: BinaryIO, chunk_size: int): + async def _iter_chunks(self, stream: BinaryIO, chunk_size: int) -> AsyncIterator[bytes]: """Iterate over a stream in chunks, yielding each chunk asynchronously.""" loop = asyncio.get_running_loop() while True: @@ -602,9 +642,9 @@ async def _send_and_receive(self, request: bytes) -> IcapResponse: await asyncio.wait_for(self._writer.drain(), timeout=self._timeout) # Receive response - response_data = await self._receive_response() + response = await self._receive_response() - logger.debug(f"Received {len(response_data)} bytes from ICAP server") + logger.debug(f"Received response: {response.status_code} {response.status_message}") except asyncio.TimeoutError as e: raise IcapTimeoutError(f"Request to {self.host}:{self.port} timed out") from e @@ -618,11 +658,6 @@ async def _send_and_receive(self, request: bytes) -> IcapResponse: self._reader = None raise IcapConnectionError(f"Connection error with {self.host}:{self.port}: {e}") from e - try: - response = IcapResponse.parse(response_data) - except ValueError as e: - raise IcapProtocolError(f"Failed to parse ICAP response: {e}") from e - # Check for server errors if 500 <= response.status_code < 600: raise IcapServerError( @@ -631,8 +666,8 @@ async def _send_and_receive(self, request: bytes) -> IcapResponse: return response - async def _receive_response(self) -> bytes: - """Receive and return raw ICAP response data.""" + async def _receive_response(self) -> IcapResponse: + """Receive and parse ICAP response from the server.""" if self._reader is None: raise IcapConnectionError("Not connected to ICAP server") @@ -649,6 +684,13 @@ async def _receive_response(self) -> bytes: if not chunk: break response_data += chunk + + # Prevent DoS from endless header data + if len(response_data) > self.MAX_HEADER_SIZE: + raise IcapProtocolError( + f"Response header section exceeds maximum size " + f"({self.MAX_HEADER_SIZE:,} bytes)" + ) except asyncio.TimeoutError: raise IcapTimeoutError( f"Timeout reading response from {self.host}:{self.port}" @@ -659,24 +701,14 @@ async def _receive_response(self) -> bytes: header_section, body_start = response_data.split(header_end_marker, 1) headers_str = header_section.decode("utf-8", errors="ignore") - content_length = None - is_chunked = False - for line in headers_str.split("\r\n")[1:]: - if ":" in line: - key, value = line.split(":", 1) - key_lower = key.strip().lower() - value_stripped = value.strip().lower() - if key_lower == "content-length": - try: - content_length = int(value.strip()) - except ValueError: - raise IcapProtocolError( - f"Invalid Content-Length header: {value.strip()!r}" - ) from None - elif key_lower == "transfer-encoding" and "chunked" in value_stripped: - is_chunked = True - - if content_length is not None: + # Parse headers to determine body handling + headers = parse_response_headers(headers_str) + + if headers.content_length is not None: + content_length = headers.content_length + # Validate content length against maximum allowed size + validate_content_length(content_length, self._max_response_size) + # Read exactly Content-Length bytes logger.debug(f"Reading {content_length} bytes of body content") response_data = header_section + header_end_marker @@ -704,14 +736,14 @@ async def _receive_response(self) -> bytes: f"Incomplete response: expected {content_length} bytes, got {bytes_read}" ) - elif is_chunked: + elif headers.is_chunked: # Read chunked transfer encoding logger.debug("Reading chunked response body") response_data = header_section + header_end_marker chunked_body = await self._read_chunked_body(body_start) response_data += chunked_body - return response_data + return IcapResponse.parse(response_data) async def _read_chunked_body(self, initial_data: bytes) -> bytes: """Read a chunked transfer encoded body. @@ -744,14 +776,32 @@ async def _read_chunked_body(self, initial_data: bytes) -> bytes: f"Timeout reading chunked body from {self.host}:{self.port}" ) from None - # Parse chunk size (hex) + # Parse and validate chunk size size_line, buffer = buffer.split(b"\r\n", 1) - try: - chunk_size = int(size_line.split(b";")[0].strip(), 16) - except ValueError: - raise IcapProtocolError(f"Invalid chunk size in response: {size_line!r}") from None + chunk_size = parse_chunk_size(size_line, self._max_response_size) if chunk_size == 0: + # Final chunk - consume trailing CRLF (and any trailer headers) + # Per RFC 7230, after the 0-size chunk there may be trailer headers + # followed by a final CRLF. We need to read until we see the empty line. + while True: + while b"\r\n" not in buffer: + try: + chunk = await asyncio.wait_for( + self._reader.read(self.BUFFER_SIZE), + timeout=self._timeout, + ) + if not chunk: + break + buffer += chunk + except asyncio.TimeoutError: + break + if b"\r\n" not in buffer: + break + line, buffer = buffer.split(b"\r\n", 1) + if not line: + # Empty line signals end of chunked body + break break # Read chunk data @@ -770,6 +820,10 @@ async def _read_chunked_body(self, initial_data: bytes) -> bytes: ) from None body += buffer[:chunk_size] + + # Validate total body size against maximum allowed + validate_body_size(len(body), self._max_response_size) + buffer = buffer[chunk_size + 2 :] return body @@ -795,51 +849,38 @@ async def _send_with_preview( raise IcapConnectionError("Not connected to ICAP server") try: - # Determine preview and remainder portions - preview_data = body[:preview_size] - remainder_data = body[preview_size:] - is_complete = len(body) <= preview_size + # Prepare preview data using shared utility + preview = prepare_preview_data( + body, preview_size, self._encode_chunked, self._encode_chunk_terminator + ) logger.debug( - f"Sending preview: {len(preview_data)} bytes, " - f"remainder: {len(remainder_data)} bytes, " - f"complete in preview: {is_complete}" + f"Sending preview: {preview_size} bytes, " + f"remainder: {len(preview.remainder)} bytes, " + f"complete in preview: {preview.is_complete}" ) - # Build the preview chunk - # Per RFC 3507 Section 4.5, use "ieof" extension on the zero-length - # terminator chunk when the entire body fits in preview - preview_chunk = self._encode_chunked(preview_data) - if is_complete: - # Use ieof on zero-length chunk to indicate no more data - preview_chunk += b"0; ieof\r\n\r\n" - else: - # Normal zero-length terminator for preview section - preview_chunk += self._encode_chunk_terminator() - # Send request with preview - self._writer.write(request + preview_chunk) + self._writer.write(request + preview.preview_chunk) await asyncio.wait_for(self._writer.drain(), timeout=self._timeout) # Receive initial response (could be 100 Continue, 204, or 200) - response_data = await self._receive_response() - response = IcapResponse.parse(response_data) + response = await self._receive_response() # If server responds with 100 Continue, send the rest of the body if response.status_code == 100: logger.debug("Received 100 Continue, sending remainder of body") # Send the remainder of the body - if remainder_data: - self._writer.write(self._encode_chunked(remainder_data)) + if preview.remainder: + self._writer.write(self._encode_chunked(preview.remainder)) # Send final zero-length chunk self._writer.write(self._encode_chunk_terminator()) await asyncio.wait_for(self._writer.drain(), timeout=self._timeout) # Receive final response - response_data = await self._receive_response() - response = IcapResponse.parse(response_data) + response = await self._receive_response() # Check for server errors if 500 <= response.status_code < 600: diff --git a/src/icap/icap.py b/src/icap/icap.py index ffaf275..6533956 100644 --- a/src/icap/icap.py +++ b/src/icap/icap.py @@ -4,7 +4,13 @@ from pathlib import Path from typing import Any, BinaryIO, Dict, Iterator, Optional, Union -from ._protocol import IcapProtocol +from ._protocol import ( + IcapProtocol, + parse_chunk_size, + parse_response_headers, + prepare_preview_data, + validate_body_size, +) from .exception import IcapConnectionError, IcapProtocolError, IcapServerError, IcapTimeoutError from .response import IcapResponse @@ -67,12 +73,19 @@ class IcapClient(IcapProtocol): - IcapResponse: Response object returned by all methods """ + # Default maximum response size (100MB) + DEFAULT_MAX_RESPONSE_SIZE: int = 104_857_600 + + # Maximum header section size (64KB) - prevents DoS from endless headers + MAX_HEADER_SIZE: int = 65536 + def __init__( self, address: str, port: int = IcapProtocol.DEFAULT_PORT, timeout: int = 10, ssl_context: Optional[ssl.SSLContext] = None, + max_response_size: int = DEFAULT_MAX_RESPONSE_SIZE, ) -> None: """ Initialize ICAP client. @@ -85,6 +98,10 @@ def __init__( the connection will be wrapped with SSL/TLS. You can create a context using ssl.create_default_context() for standard TLS, or customize it for specific certificate requirements. + max_response_size: Maximum allowed response size in bytes (default: 100MB). + This limits both Content-Length values and individual chunk sizes + in chunked transfer encoding. Increase this if you need to scan + files larger than 100MB. Must be a positive integer. Example: >>> # Standard TLS with system CA certificates @@ -99,13 +116,18 @@ def __init__( >>> ssl_ctx = ssl.create_default_context() >>> ssl_ctx.load_cert_chain('/path/to/client.pem', '/path/to/key.pem') >>> client = IcapClient('icap.example.com', ssl_context=ssl_ctx) + + >>> # Scanning large files (up to 500MB) + >>> client = IcapClient('localhost', max_response_size=500_000_000) """ + if max_response_size <= 0: + raise ValueError("max_response_size must be a positive integer") self._address: str = address self._port: int = port self._timeout: int = timeout self._ssl_context: Optional[ssl.SSLContext] = ssl_context + self._max_response_size: int = max_response_size self._socket: Optional[Union[socket.socket, ssl.SSLSocket]] = None - self._connected: bool = False logger.debug( f"Initialized IcapClient for {address}:{port} (SSL: {ssl_context is not None})" ) @@ -127,7 +149,7 @@ def port(self, p: int) -> None: @property def is_connected(self) -> bool: """Return True if the client is currently connected to the server.""" - return self._connected + return self._socket is not None def connect(self) -> None: """Connect to the ICAP server. @@ -140,7 +162,7 @@ def connect(self) -> None: SSL/TLS handshake errors. IcapTimeoutError: If connection times out. """ - if self._connected: + if self._socket is not None: logger.debug("Already connected") return @@ -158,7 +180,6 @@ def connect(self) -> None: else: self._socket = sock - self._connected = True logger.info( f"Connected to {self.host}:{self.port} (SSL: {self._ssl_context is not None})" ) @@ -189,7 +210,6 @@ def disconnect(self) -> None: except OSError as e: logger.warning(f"Error while disconnecting: {e}") self._socket = None - self._connected = False def __enter__(self) -> "IcapClient": """Context manager entry.""" @@ -203,15 +223,31 @@ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> bool: def options(self, service: str) -> IcapResponse: """ - Send OPTIONS request to ICAP server. + Send OPTIONS request to query ICAP server capabilities. + + The OPTIONS request retrieves information about the ICAP service, + including supported methods, preview size, and transfer encodings. Args: service: ICAP service name (e.g., "avscan") Returns: - IcapResponse object + IcapResponse with headers containing server capabilities: + - Methods: Supported ICAP methods (e.g., "RESPMOD, REQMOD") + - Preview: Suggested preview size in bytes for this service + - Transfer-Preview: File extensions that benefit from preview + - Max-Connections: Maximum concurrent connections allowed + - Options-TTL: How long (seconds) to cache this OPTIONS response + - Service-ID: Unique identifier for this service instance + + Example: + >>> with IcapClient('localhost') as client: + ... response = client.options("avscan") + ... preview_size = int(response.headers.get("Preview", 0)) + ... methods = response.headers.get("Methods", "") + ... print(f"Preview: {preview_size}, Methods: {methods}") """ - if not self._connected: + if self._socket is None: self.connect() logger.debug(f"Sending OPTIONS request for service: {service}") @@ -221,7 +257,7 @@ def options(self, service: str) -> IcapResponse: ) headers = { "Host": f"{self.host}:{self.port}", - "User-Agent": "Python-ICAP-Client/1.0", + "User-Agent": self.USER_AGENT, "Encapsulated": "null-body=0", } @@ -254,7 +290,7 @@ def respmod( Returns: IcapResponse object """ - if not self._connected: + if self._socket is None: self.connect() logger.debug(f"Sending RESPMOD request for service: {service}") @@ -277,7 +313,7 @@ def respmod( icap_headers = { "Host": f"{self.host}:{self.port}", - "User-Agent": "Python-ICAP-Client/1.0", + "User-Agent": self.USER_AGENT, "Allow": "204", } @@ -338,7 +374,7 @@ def reqmod( Returns: IcapResponse object """ - if not self._connected: + if self._socket is None: self.connect() logger.debug(f"Sending REQMOD request for service: {service}") @@ -351,7 +387,7 @@ def reqmod( icap_headers = { "Host": f"{self.host}:{self.port}", - "User-Agent": "Python-ICAP-Client/1.0", + "User-Agent": self.USER_AGENT, "Allow": "204", } @@ -417,9 +453,12 @@ def scan_stream( stream: File-like object (must support read()) service: ICAP service name (default: "avscan") filename: Optional filename to use in HTTP headers - chunk_size: If > 0, use chunked streaming to avoid loading entire - file into memory. Set to e.g. 65536 for 64KB chunks. - If 0 (default), reads entire stream into memory. + chunk_size: Controls memory usage for large files. + - 0 (default): Reads entire stream into memory before sending. + Simple but may exhaust memory for very large files. + - >0: Uses chunked streaming, reading and sending in chunks of + this size (bytes). Set to 65536 for 64KB chunks, 1048576 for 1MB. + Recommended for files larger than available memory. Returns: IcapResponse object @@ -466,7 +505,7 @@ def _scan_stream_chunked( Returns: IcapResponse object """ - if not self._connected: + if self._socket is None: self.connect() if self._socket is None: @@ -491,7 +530,7 @@ def _scan_stream_chunked( icap_headers = { "Host": f"{self.host}:{self.port}", - "User-Agent": "Python-ICAP-Client/1.0", + "User-Agent": self.USER_AGENT, "Allow": "204", "Encapsulated": f"req-hdr=0, res-hdr={req_hdr_len}, res-body={req_hdr_len + res_hdr_len}", } @@ -521,7 +560,7 @@ def _scan_stream_chunked( except socket.timeout as e: raise IcapTimeoutError(f"Request to {self.host}:{self.port} timed out") from e except OSError as e: - self._connected = False + self._socket = None raise IcapConnectionError(f"Connection error with {self.host}:{self.port}: {e}") from e def _iter_chunks(self, stream: BinaryIO, chunk_size: int) -> Iterator[bytes]: @@ -551,6 +590,13 @@ def _receive_response(self) -> IcapResponse: break response_data += chunk + # Prevent DoS from endless header data + if len(response_data) > self.MAX_HEADER_SIZE: + raise IcapProtocolError( + f"Response header section exceeds maximum size " + f"({self.MAX_HEADER_SIZE:,} bytes)" + ) + # Parse headers to determine if there's a body if header_end_marker in response_data: header_section, body_start = response_data.split(header_end_marker, 1) @@ -570,6 +616,13 @@ def _receive_response(self) -> IcapResponse: break if content_length is not None: + # Validate content length against maximum allowed size + if content_length > self._max_response_size: + raise IcapProtocolError( + f"Response Content-Length ({content_length:,} bytes) exceeds " + f"maximum allowed size ({self._max_response_size:,} bytes)" + ) + response_data = header_section + header_end_marker bytes_read = len(body_start) response_data += body_start @@ -594,7 +647,7 @@ def _receive_response(self) -> IcapResponse: except socket.timeout as e: raise IcapTimeoutError(f"Request to {self.host}:{self.port} timed out") from e except OSError as e: - self._connected = False + self._socket = None raise IcapConnectionError(f"Connection error with {self.host}:{self.port}: {e}") from e try: @@ -665,30 +718,23 @@ def _send_and_receive(self, request: bytes) -> IcapResponse: break response_data += chunk + # Prevent DoS from endless header data + if len(response_data) > self.MAX_HEADER_SIZE: + raise IcapProtocolError( + f"Response header section exceeds maximum size " + f"({self.MAX_HEADER_SIZE:,} bytes)" + ) + # Parse headers to determine if there's a body and how to read it if header_end_marker in response_data: header_section, body_start = response_data.split(header_end_marker, 1) headers_str = header_section.decode("utf-8", errors="ignore") - # Parse headers into dict for easier lookup - content_length = None - is_chunked = False - for line in headers_str.split("\r\n")[1:]: - if ":" in line: - key, value = line.split(":", 1) - key_lower = key.strip().lower() - value_stripped = value.strip().lower() - if key_lower == "content-length": - try: - content_length = int(value.strip()) - except ValueError: - raise IcapProtocolError( - f"Invalid Content-Length header: {value.strip()!r}" - ) from None - elif key_lower == "transfer-encoding" and "chunked" in value_stripped: - is_chunked = True + # Parse headers to determine body handling + headers = parse_response_headers(headers_str) - if content_length is not None: + if headers.content_length is not None: + content_length = headers.content_length # Read exactly Content-Length bytes logger.debug(f"Reading {content_length} bytes of body content") response_data = header_section + header_end_marker @@ -710,7 +756,7 @@ def _send_and_receive(self, request: bytes) -> IcapResponse: f"Incomplete response: expected {content_length} bytes, got {bytes_read}" ) - elif is_chunked: + elif headers.is_chunked: # Read chunked transfer encoding logger.debug("Reading chunked response body") response_data = header_section + header_end_marker @@ -726,7 +772,7 @@ def _send_and_receive(self, request: bytes) -> IcapResponse: except socket.timeout as e: raise IcapTimeoutError(f"Request to {self.host}:{self.port} timed out") from e except OSError as e: - self._connected = False + self._socket = None raise IcapConnectionError(f"Connection error with {self.host}:{self.port}: {e}") from e try: @@ -765,16 +811,26 @@ def _read_chunked_body(self, initial_data: bytes) -> bytes: raise IcapProtocolError("Connection closed before chunked body complete") buffer += chunk - # Parse chunk size (hex) + # Parse and validate chunk size size_line, buffer = buffer.split(b"\r\n", 1) - try: - # Chunk size may have extensions after semicolon, ignore them - chunk_size = int(size_line.split(b";")[0].strip(), 16) - except ValueError: - raise IcapProtocolError(f"Invalid chunk size in response: {size_line!r}") from None + chunk_size = parse_chunk_size(size_line, self._max_response_size) if chunk_size == 0: - # Final chunk - read trailing CRLF + # Final chunk - consume trailing CRLF (and any trailer headers) + # Per RFC 7230, after the 0-size chunk there may be trailer headers + # followed by a final CRLF. We need to read until we see the empty line. + while True: + while b"\r\n" not in buffer: + chunk = self._socket.recv(self.BUFFER_SIZE) + if not chunk: + break + buffer += chunk + if b"\r\n" not in buffer: + break + line, buffer = buffer.split(b"\r\n", 1) + if not line: + # Empty line signals end of chunked body + break break # Read chunk data @@ -786,6 +842,10 @@ def _read_chunked_body(self, initial_data: bytes) -> bytes: # Extract chunk data (excluding trailing CRLF) body += buffer[:chunk_size] + + # Validate total body size against maximum allowed + validate_body_size(len(body), self._max_response_size) + buffer = buffer[chunk_size + 2 :] # Skip chunk data and CRLF return body @@ -809,30 +869,19 @@ def _send_with_preview(self, request: bytes, body: bytes, preview_size: int) -> raise IcapConnectionError("Not connected to ICAP server") try: - # Determine preview and remainder portions - preview_data = body[:preview_size] - remainder_data = body[preview_size:] - is_complete = len(body) <= preview_size + # Prepare preview data using shared utility + preview = prepare_preview_data( + body, preview_size, self._encode_chunked, self._encode_chunk_terminator + ) logger.debug( - f"Sending preview: {len(preview_data)} bytes, " - f"remainder: {len(remainder_data)} bytes, " - f"complete in preview: {is_complete}" + f"Sending preview: {preview_size} bytes, " + f"remainder: {len(preview.remainder)} bytes, " + f"complete in preview: {preview.is_complete}" ) - # Build the preview chunk - # Per RFC 3507 Section 4.5, use "ieof" extension on the zero-length - # terminator chunk when the entire body fits in preview - preview_chunk = self._encode_chunked(preview_data) - if is_complete: - # Use ieof on zero-length chunk to indicate no more data - preview_chunk += b"0; ieof\r\n\r\n" - else: - # Normal zero-length terminator for preview section - preview_chunk += self._encode_chunk_terminator() - # Send request with preview - self._socket.sendall(request + preview_chunk) + self._socket.sendall(request + preview.preview_chunk) # Receive initial response (could be 100 Continue, 204, or 200) response = self._receive_response() @@ -842,8 +891,8 @@ def _send_with_preview(self, request: bytes, body: bytes, preview_size: int) -> logger.debug("Received 100 Continue, sending remainder of body") # Send the remainder of the body - if remainder_data: - self._socket.sendall(self._encode_chunked(remainder_data)) + if preview.remainder: + self._socket.sendall(self._encode_chunked(preview.remainder)) # Send final zero-length chunk self._socket.sendall(self._encode_chunk_terminator()) @@ -857,5 +906,5 @@ def _send_with_preview(self, request: bytes, body: bytes, preview_size: int) -> except socket.timeout as e: raise IcapTimeoutError(f"Request to {self.host}:{self.port} timed out") from e except OSError as e: - self._connected = False + self._socket = None raise IcapConnectionError(f"Connection error with {self.host}:{self.port}: {e}") from e diff --git a/src/icap/pytest_plugin/call_record.py b/src/icap/pytest_plugin/call_record.py new file mode 100644 index 0000000..2fe58ea --- /dev/null +++ b/src/icap/pytest_plugin/call_record.py @@ -0,0 +1,245 @@ +""" +Call recording for mock ICAP clients. + +This module provides the MockCall dataclass for recording method invocations +and the MockResponseExhaustedError for queue exhaustion. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from icap import IcapResponse + + +class MockResponseExhaustedError(Exception): + """ + Raised when all queued mock responses have been consumed. + + This error indicates that more method calls were made than responses + were configured. Configure additional responses or use a callback + for dynamic response generation. + + Example: + >>> client = MockIcapClient() + >>> client.on_respmod( + ... IcapResponseBuilder().clean().build(), + ... IcapResponseBuilder().virus().build(), + ... ) + >>> client.scan_bytes(b"file1") # Returns clean + >>> client.scan_bytes(b"file2") # Returns virus + >>> client.scan_bytes(b"file3") # Raises MockResponseExhaustedError + """ + + pass + + +@dataclass +class MockCall: + """ + Record of a single method call on a MockIcapClient. + + MockCall instances are created automatically when methods are called on + the mock client and stored in the `calls` list for later inspection. + + Attributes: + method: Name of the method that was called (e.g., "scan_bytes", "options"). + timestamp: Unix timestamp when the call was made (from time.time()). + kwargs: Dictionary of keyword arguments passed to the method. The keys + depend on the method called: + - options: {"service": str} + - respmod: {"service": str, "http_request": bytes, "http_response": bytes, + "headers": dict|None, "preview": int|None} + - reqmod: {"service": str, "http_request": bytes, "http_body": bytes|None, + "headers": dict|None} + - scan_bytes: {"data": bytes, "service": str, "filename": str|None} + - scan_file: {"filepath": str, "service": str, "data": bytes} + - scan_stream (sync): {"data": bytes, "service": str, "filename": str|None, + "chunk_size": int} + - scan_stream (async): {"data": bytes, "service": str, "filename": str|None} + Note: async scan_stream doesn't have chunk_size (matches AsyncIcapClient API) + response: The IcapResponse returned by the call (None if exception raised). + exception: The exception raised by the call (None if successful). + matched_by: How the response was determined: "matcher", "callback", "queue", + or "default". Useful for debugging which configuration produced the response. + call_index: Position in the call history (0-based). + + Properties: + data: Shorthand for kwargs.get("data") - the scanned content. + filename: Shorthand for kwargs.get("filename") - the filename if provided. + service: Shorthand for kwargs.get("service") - the service name. + succeeded: True if the call completed without raising an exception. + was_clean: True if the response indicates no modification (clean scan). + was_virus: True if the response indicates virus detection. + + Example - Basic inspection: + >>> client = MockIcapClient() + >>> client.scan_bytes(b"test", filename="test.txt") + >>> call = client.calls[0] + >>> call.method + 'scan_bytes' + >>> call.data + b'test' + >>> call.filename + 'test.txt' + >>> call.was_clean + True + >>> call.matched_by + 'default' + + Example - Checking virus detection: + >>> client.on_respmod(IcapResponseBuilder().virus("Trojan").build()) + >>> client.scan_bytes(b"malware") + >>> call = client.last_call + >>> call.was_virus + True + >>> call.response.headers["X-Virus-ID"] + 'Trojan' + + Example - Exception tracking: + >>> client.on_respmod(raises=IcapTimeoutError("Timeout")) + >>> try: + ... client.scan_bytes(b"data") + ... except IcapTimeoutError: + ... pass + >>> call = client.calls[-1] + >>> call.succeeded + False + >>> type(call.exception).__name__ + 'IcapTimeoutError' + """ + + method: str + timestamp: float + kwargs: dict[str, Any] = field(default_factory=dict) + + # Track response/exception and how it was determined + response: IcapResponse | None = None + exception: Exception | None = None + matched_by: str | None = None # "matcher", "callback", "queue", or "default" + call_index: int = 0 + + # === Convenience Properties === + + @property + def data(self) -> bytes | None: + """ + Get the scanned data if this was a scan call. + + Returns: + The bytes that were scanned, or None if not a scan call. + """ + return self.kwargs.get("data") + + @property + def filename(self) -> str | None: + """ + Get the filename if provided to the call. + + Returns: + The filename argument, or None if not provided. + """ + return self.kwargs.get("filename") + + @property + def service(self) -> str | None: + """ + Get the service name used in the call. + + Returns: + The service name (e.g., "avscan"), or None if not provided. + """ + return self.kwargs.get("service") + + @property + def succeeded(self) -> bool: + """ + Check if the call completed successfully (didn't raise an exception). + + Returns: + True if the call returned a response, False if it raised an exception. + """ + return self.exception is None + + @property + def was_clean(self) -> bool: + """ + Check if the call resulted in a clean (no modification) response. + + Returns: + True if the call succeeded and returned a 204 No Modification response. + False if an exception was raised or the response indicates modification. + """ + return ( + self.exception is None + and self.response is not None + and self.response.is_no_modification + ) + + @property + def was_virus(self) -> bool: + """ + Check if the call resulted in a virus detection. + + Returns: + True if the call succeeded and the response contains an X-Virus-ID header. + False if an exception was raised, response is clean, or no virus header present. + """ + return ( + self.exception is None + and self.response is not None + and not self.response.is_no_modification + and "X-Virus-ID" in self.response.headers + ) + + def __repr__(self) -> str: + """ + Return a rich string representation for debugging. + + Format: method(data=..., filename=...) -> result + Where result is one of: clean, virus(name), raised ExceptionType + """ + parts = [f"{self.method}("] + + # Show truncated data if present + if self.data is not None: + if len(self.data) > 20: + parts.append(f"data={self.data[:20]!r}...") + else: + parts.append(f"data={self.data!r}") + + # Show filename if present + if self.filename: + if self.data is not None: + parts.append(f", filename={self.filename!r}") + else: + parts.append(f"filename={self.filename!r}") + + # Show service if different from default + if self.service and self.service != "avscan": + if self.data is not None or self.filename: + parts.append(f", service={self.service!r}") + else: + parts.append(f"service={self.service!r}") + + parts.append(")") + + # Show result + if self.was_clean: + parts.append(" -> clean") + elif self.was_virus: + # was_virus property guarantees self.response is not None + assert self.response is not None + virus_id = self.response.headers.get("X-Virus-ID", "unknown") + parts.append(f" -> virus({virus_id})") + elif self.exception: + parts.append(f" -> raised {type(self.exception).__name__}") + elif self.response: + parts.append(f" -> {self.response.status_code}") + + return "".join(parts) + + +__all__ = ["MockCall", "MockResponseExhaustedError"] diff --git a/src/icap/pytest_plugin/matchers.py b/src/icap/pytest_plugin/matchers.py new file mode 100644 index 0000000..9c7bb8f --- /dev/null +++ b/src/icap/pytest_plugin/matchers.py @@ -0,0 +1,244 @@ +""" +Content matchers for conditional mock responses. + +This module provides declarative matching rules for MockIcapClient, +allowing responses to be configured based on service name, filename, +or content patterns. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from icap import IcapResponse + + from .mock_client import MockIcapClient + + +@dataclass +class ResponseMatcher: + """ + A rule that matches scan calls and returns a specific response. + + ResponseMatcher provides declarative conditional responses based on + service name, filename, or content. Matchers are checked in registration + order; the first match wins. + + Attributes: + service: Exact service name to match (e.g., "avscan"). + filename: Exact filename to match (e.g., "malware.exe"). + filename_pattern: Compiled regex pattern to match against filename. + data_contains: Bytes that must be present in the scanned content. + response: The IcapResponse to return when this matcher matches. + times: Maximum number of times this matcher can be used (None = unlimited). + + Matching Logic: + All specified criteria must match (AND logic). Unspecified criteria + (None values) are ignored. For example: + + - `service="avscan"` matches any call with service="avscan" + - `service="avscan", filename="test.exe"` requires both to match + - `data_contains=b"EICAR"` matches any content containing those bytes + + Example: + >>> # Create a matcher that triggers on .exe files + >>> matcher = ResponseMatcher( + ... filename_pattern=re.compile(r".*\\.exe$"), + ... response=IcapResponseBuilder().virus("Blocked.Exe").build(), + ... ) + >>> matcher.matches(service="avscan", filename="test.exe", data=b"content") + True + >>> matcher.matches(service="avscan", filename="test.pdf", data=b"content") + False + + See Also: + MatcherBuilder: Fluent API for creating matchers via when(). + MockIcapClient.when: Register matchers on the mock client. + """ + + service: str | None = None + filename: str | None = None + filename_pattern: re.Pattern[str] | None = None + data_contains: bytes | None = None + response: IcapResponse | None = None + times: int | None = None + _match_count: int = field(default=0, repr=False) + + def matches(self, **kwargs: Any) -> bool: + """ + Check if this matcher applies to the given call kwargs. + + All specified criteria must match (AND logic). Criteria that are None + are not checked. + + Args: + **kwargs: Call arguments including data, service, filename, etc. + + Returns: + True if all specified criteria match, False otherwise. + """ + # Check service match + if self.service is not None: + if kwargs.get("service") != self.service: + return False + + # Check exact filename match + if self.filename is not None: + if kwargs.get("filename") != self.filename: + return False + + # Check filename pattern match + if self.filename_pattern is not None: + filename = kwargs.get("filename") + if filename is None or not self.filename_pattern.match(filename): + return False + + # Check data contains + if self.data_contains is not None: + data = kwargs.get("data", b"") + if self.data_contains not in data: + return False + + return True + + def consume(self) -> IcapResponse: + """ + Return the response and increment the match count. + + Returns: + The configured IcapResponse. + + Raises: + ValueError: If no response is configured. + """ + if self.response is None: + raise ValueError("No response configured for this matcher") + self._match_count += 1 + return self.response + + def is_exhausted(self) -> bool: + """ + Check if this matcher has reached its usage limit. + + Returns: + True if times limit is set and has been reached, False otherwise. + """ + if self.times is None: + return False + return self._match_count >= self.times + + +class MatcherBuilder: + """ + Fluent builder for creating and registering ResponseMatchers. + + MatcherBuilder provides a readable, chainable API for configuring + conditional responses. Created via MockIcapClient.when(), it collects + match criteria and registers the matcher when respond() is called. + + Example - Simple filename matching: + >>> client = MockIcapClient() + >>> client.when(filename="malware.exe").respond( + ... IcapResponseBuilder().virus("Known.Malware").build() + ... ) + + Example - Pattern matching with regex: + >>> client.when(filename_matches=r".*\\.exe$").respond( + ... IcapResponseBuilder().virus("Policy.BlockedExecutable").build() + ... ) + + Example - Content-based matching: + >>> client.when(data_contains=b"EICAR").respond( + ... IcapResponseBuilder().virus("EICAR-Test").build() + ... ) + + Example - Combined criteria (AND logic): + >>> client.when( + ... service="avscan", + ... filename_matches=r".*\\.docx$", + ... data_contains=b"PK\\x03\\x04", # ZIP header (Office files are ZIPs) + ... ).respond( + ... IcapResponseBuilder().virus("Macro.Suspicious").build() + ... ) + + Example - Limited use matcher: + >>> client.when(data_contains=b"bad").respond( + ... IcapResponseBuilder().virus().build(), + ... times=2, # Only match first 2 times + ... ) + + See Also: + MockIcapClient.when: Entry point for creating matchers. + ResponseMatcher: The underlying matcher dataclass. + """ + + def __init__( + self, + client: MockIcapClient, + *, + service: str | None = None, + filename: str | None = None, + filename_matches: str | None = None, + data_contains: bytes | None = None, + ) -> None: + """ + Initialize the builder with match criteria. + + This constructor is not called directly; use MockIcapClient.when() instead. + + Args: + client: The mock client to register the matcher with. + service: Exact service name to match. + filename: Exact filename to match. + filename_matches: Regex pattern string to match against filename. + data_contains: Bytes that must be present in scanned content. + """ + self._client = client + self._service = service + self._filename = filename + self._filename_pattern = re.compile(filename_matches) if filename_matches else None + self._data_contains = data_contains + + def respond( + self, + response: IcapResponse, + *, + times: int | None = None, + ) -> MockIcapClient: + """ + Register this matcher with the specified response. + + Creates a ResponseMatcher from the configured criteria and registers + it with the client. Matchers are checked in registration order. + + Args: + response: The IcapResponse to return when this matcher matches. + times: Maximum number of times this matcher can be used. + None means unlimited (default). + + Returns: + The MockIcapClient for method chaining. + + Example: + >>> client.when(filename="test.exe").respond( + ... IcapResponseBuilder().virus().build() + ... ).when(filename="safe.txt").respond( + ... IcapResponseBuilder().clean().build() + ... ) + """ + matcher = ResponseMatcher( + service=self._service, + filename=self._filename, + filename_pattern=self._filename_pattern, + data_contains=self._data_contains, + response=response, + times=times, + ) + self._client._matchers.append(matcher) + return self._client + + +__all__ = ["ResponseMatcher", "MatcherBuilder"] diff --git a/src/icap/pytest_plugin/mock.py b/src/icap/pytest_plugin/mock.py index adb5f2e..6bc0a20 100644 --- a/src/icap/pytest_plugin/mock.py +++ b/src/icap/pytest_plugin/mock.py @@ -1,9 +1,14 @@ """ Mock ICAP clients for testing without network I/O. -This module provides mock implementations of IcapClient and AsyncIcapClient -that can be used in tests without requiring a real ICAP server. The mocks -support configurable responses, call recording, and rich assertion capabilities. +This module re-exports mock components from their individual modules +for backward compatibility. New code should import from the specific modules: + + from icap.pytest_plugin.mock_client import MockIcapClient + from icap.pytest_plugin.mock_async import MockAsyncIcapClient + from icap.pytest_plugin.matchers import ResponseMatcher, MatcherBuilder + from icap.pytest_plugin.call_record import MockCall, MockResponseExhaustedError + from icap.pytest_plugin.protocols import ResponseCallback, AsyncResponseCallback Key Features: - **Response Configuration**: Set static, sequential, or dynamic responses. @@ -38,25 +43,6 @@ >>> # Verify calls were made >>> client.assert_called("scan_bytes", times=2) -Response Sequence Example: - >>> client = MockIcapClient() - >>> client.on_respmod( - ... IcapResponseBuilder().clean().build(), - ... IcapResponseBuilder().virus("Trojan").build(), - ... IcapResponseBuilder().clean().build(), - ... ) - >>> client.scan_bytes(b"file1").is_no_modification # True - >>> client.scan_bytes(b"file2").is_no_modification # False - >>> client.scan_bytes(b"file3").is_no_modification # True - -Content Matcher Example: - >>> client = MockIcapClient() - >>> client.when(filename_matches=r".*\\.exe$").respond( - ... IcapResponseBuilder().virus("Blocked.Exe").build() - ... ) - >>> client.scan_bytes(b"data", filename="app.exe").is_no_modification # False - >>> client.scan_bytes(b"data", filename="doc.pdf").is_no_modification # True - See Also: icap.pytest_plugin: Main package with fixtures and builders. IcapResponseBuilder: Fluent builder for creating test responses. @@ -64,2140 +50,20 @@ from __future__ import annotations -import inspect -import re -import time -from collections import deque -from dataclasses import dataclass, field -from pathlib import Path -from typing import TYPE_CHECKING, Any, BinaryIO, Protocol, cast - -from .builder import IcapResponseBuilder - -if TYPE_CHECKING: - from icap import IcapResponse - - -class ResponseCallback(Protocol): - """ - Protocol for synchronous response callbacks. - - Callbacks receive the request context and return an IcapResponse. - Use this for dynamic response generation based on content, filename, - or service name. - - The callback signature is flexible: - - Required: `data` (bytes) - the content being scanned - - Optional keyword arguments: `service`, `filename`, and others - - Example signatures (all valid): - >>> def simple_callback(data: bytes, **kwargs) -> IcapResponse: ... - >>> def detailed_callback( - ... data: bytes, - ... *, - ... service: str, - ... filename: str | None, - ... **kwargs - ... ) -> IcapResponse: ... - - Example: - >>> def eicar_detector(data: bytes, **kwargs) -> IcapResponse: - ... if b"EICAR" in data: - ... return IcapResponseBuilder().virus("EICAR-Test").build() - ... return IcapResponseBuilder().clean().build() - >>> - >>> client = MockIcapClient() - >>> client.on_respmod(callback=eicar_detector) - - See Also: - AsyncResponseCallback: Async version for MockAsyncIcapClient. - MockIcapClient.on_respmod: Configure callbacks for scan methods. - """ - - def __call__( - self, - data: bytes, - *, - service: str, - filename: str | None = None, - **kwargs: Any, - ) -> IcapResponse: ... - - -class AsyncResponseCallback(Protocol): - """ - Protocol for asynchronous response callbacks. - - Async version of ResponseCallback for use with MockAsyncIcapClient. - Callbacks receive the request context and return an IcapResponse. - - Note: MockAsyncIcapClient also accepts synchronous callbacks for - convenience - they will be called directly without awaiting. - - Example: - >>> async def async_scanner(data: bytes, **kwargs) -> IcapResponse: - ... # Can perform async operations if needed - ... if b"EICAR" in data: - ... return IcapResponseBuilder().virus("EICAR-Test").build() - ... return IcapResponseBuilder().clean().build() - >>> - >>> client = MockAsyncIcapClient() - >>> client.on_respmod(callback=async_scanner) - - See Also: - ResponseCallback: Sync version for MockIcapClient. - MockAsyncIcapClient.on_respmod: Configure callbacks for scan methods. - """ - - async def __call__( - self, - data: bytes, - *, - service: str, - filename: str | None = None, - **kwargs: Any, - ) -> IcapResponse: ... - - -@dataclass -class ResponseMatcher: - """ - A rule that matches scan calls and returns a specific response. - - ResponseMatcher provides declarative conditional responses based on - service name, filename, or content. Matchers are checked in registration - order; the first match wins. - - Attributes: - service: Exact service name to match (e.g., "avscan"). - filename: Exact filename to match (e.g., "malware.exe"). - filename_pattern: Compiled regex pattern to match against filename. - data_contains: Bytes that must be present in the scanned content. - response: The IcapResponse to return when this matcher matches. - times: Maximum number of times this matcher can be used (None = unlimited). - - Matching Logic: - All specified criteria must match (AND logic). Unspecified criteria - (None values) are ignored. For example: - - - `service="avscan"` matches any call with service="avscan" - - `service="avscan", filename="test.exe"` requires both to match - - `data_contains=b"EICAR"` matches any content containing those bytes - - Example: - >>> # Create a matcher that triggers on .exe files - >>> matcher = ResponseMatcher( - ... filename_pattern=re.compile(r".*\\.exe$"), - ... response=IcapResponseBuilder().virus("Blocked.Exe").build(), - ... ) - >>> matcher.matches(service="avscan", filename="test.exe", data=b"content") - True - >>> matcher.matches(service="avscan", filename="test.pdf", data=b"content") - False - - See Also: - MatcherBuilder: Fluent API for creating matchers via when(). - MockIcapClient.when: Register matchers on the mock client. - """ - - service: str | None = None - filename: str | None = None - filename_pattern: re.Pattern[str] | None = None - data_contains: bytes | None = None - response: IcapResponse | None = None - times: int | None = None - _match_count: int = field(default=0, repr=False) - - def matches(self, **kwargs: Any) -> bool: - """ - Check if this matcher applies to the given call kwargs. - - All specified criteria must match (AND logic). Criteria that are None - are not checked. - - Args: - **kwargs: Call arguments including data, service, filename, etc. - - Returns: - True if all specified criteria match, False otherwise. - """ - # Check service match - if self.service is not None: - if kwargs.get("service") != self.service: - return False - - # Check exact filename match - if self.filename is not None: - if kwargs.get("filename") != self.filename: - return False - - # Check filename pattern match - if self.filename_pattern is not None: - filename = kwargs.get("filename") - if filename is None or not self.filename_pattern.match(filename): - return False - - # Check data contains - if self.data_contains is not None: - data = kwargs.get("data", b"") - if self.data_contains not in data: - return False - - return True - - def consume(self) -> IcapResponse: - """ - Return the response and increment the match count. - - Returns: - The configured IcapResponse. - - Raises: - ValueError: If no response is configured. - """ - if self.response is None: - raise ValueError("No response configured for this matcher") - self._match_count += 1 - return self.response - - def is_exhausted(self) -> bool: - """ - Check if this matcher has reached its usage limit. - - Returns: - True if times limit is set and has been reached, False otherwise. - """ - if self.times is None: - return False - return self._match_count >= self.times - - -class MatcherBuilder: - """ - Fluent builder for creating and registering ResponseMatchers. - - MatcherBuilder provides a readable, chainable API for configuring - conditional responses. Created via MockIcapClient.when(), it collects - match criteria and registers the matcher when respond() is called. - - Example - Simple filename matching: - >>> client = MockIcapClient() - >>> client.when(filename="malware.exe").respond( - ... IcapResponseBuilder().virus("Known.Malware").build() - ... ) - - Example - Pattern matching with regex: - >>> client.when(filename_matches=r".*\\.exe$").respond( - ... IcapResponseBuilder().virus("Policy.BlockedExecutable").build() - ... ) - - Example - Content-based matching: - >>> client.when(data_contains=b"EICAR").respond( - ... IcapResponseBuilder().virus("EICAR-Test").build() - ... ) - - Example - Combined criteria (AND logic): - >>> client.when( - ... service="avscan", - ... filename_matches=r".*\\.docx$", - ... data_contains=b"PK\\x03\\x04", # ZIP header (Office files are ZIPs) - ... ).respond( - ... IcapResponseBuilder().virus("Macro.Suspicious").build() - ... ) - - Example - Limited use matcher: - >>> client.when(data_contains=b"bad").respond( - ... IcapResponseBuilder().virus().build(), - ... times=2, # Only match first 2 times - ... ) - - See Also: - MockIcapClient.when: Entry point for creating matchers. - ResponseMatcher: The underlying matcher dataclass. - """ - - def __init__( - self, - client: MockIcapClient, - *, - service: str | None = None, - filename: str | None = None, - filename_matches: str | None = None, - data_contains: bytes | None = None, - ) -> None: - """ - Initialize the builder with match criteria. - - This constructor is not called directly; use MockIcapClient.when() instead. - - Args: - client: The mock client to register the matcher with. - service: Exact service name to match. - filename: Exact filename to match. - filename_matches: Regex pattern string to match against filename. - data_contains: Bytes that must be present in scanned content. - """ - self._client = client - self._service = service - self._filename = filename - self._filename_pattern = re.compile(filename_matches) if filename_matches else None - self._data_contains = data_contains - - def respond( - self, - response: IcapResponse, - *, - times: int | None = None, - ) -> MockIcapClient: - """ - Register this matcher with the specified response. - - Creates a ResponseMatcher from the configured criteria and registers - it with the client. Matchers are checked in registration order. - - Args: - response: The IcapResponse to return when this matcher matches. - times: Maximum number of times this matcher can be used. - None means unlimited (default). - - Returns: - The MockIcapClient for method chaining. - - Example: - >>> client.when(filename="test.exe").respond( - ... IcapResponseBuilder().virus().build() - ... ).when(filename="safe.txt").respond( - ... IcapResponseBuilder().clean().build() - ... ) - """ - matcher = ResponseMatcher( - service=self._service, - filename=self._filename, - filename_pattern=self._filename_pattern, - data_contains=self._data_contains, - response=response, - times=times, - ) - self._client._matchers.append(matcher) - return self._client - - -class MockResponseExhaustedError(Exception): - """ - Raised when all queued mock responses have been consumed. - - This error indicates that more method calls were made than responses - were configured. Configure additional responses or use a callback - for dynamic response generation. - - Example: - >>> client = MockIcapClient() - >>> client.on_respmod( - ... IcapResponseBuilder().clean().build(), - ... IcapResponseBuilder().virus().build(), - ... ) - >>> client.scan_bytes(b"file1") # Returns clean - >>> client.scan_bytes(b"file2") # Returns virus - >>> client.scan_bytes(b"file3") # Raises MockResponseExhaustedError - """ - - pass - - -@dataclass -class MockCall: - """ - Record of a single method call on a MockIcapClient. - - MockCall instances are created automatically when methods are called on - the mock client and stored in the `calls` list for later inspection. - - Attributes: - method: Name of the method that was called (e.g., "scan_bytes", "options"). - timestamp: Unix timestamp when the call was made (from time.time()). - kwargs: Dictionary of keyword arguments passed to the method. The keys - depend on the method called: - - options: {"service": str} - - respmod: {"service": str, "http_request": bytes, "http_response": bytes, - "headers": dict|None, "preview": int|None} - - reqmod: {"service": str, "http_request": bytes, "http_body": bytes|None, - "headers": dict|None} - - scan_bytes: {"data": bytes, "service": str, "filename": str|None} - - scan_file: {"filepath": str, "service": str, "data": bytes} - - scan_stream (sync): {"data": bytes, "service": str, "filename": str|None, - "chunk_size": int} - - scan_stream (async): {"data": bytes, "service": str, "filename": str|None} - Note: async scan_stream doesn't have chunk_size (matches AsyncIcapClient API) - response: The IcapResponse returned by the call (None if exception raised). - exception: The exception raised by the call (None if successful). - matched_by: How the response was determined: "matcher", "callback", "queue", - or "default". Useful for debugging which configuration produced the response. - call_index: Position in the call history (0-based). - - Properties: - data: Shorthand for kwargs.get("data") - the scanned content. - filename: Shorthand for kwargs.get("filename") - the filename if provided. - service: Shorthand for kwargs.get("service") - the service name. - succeeded: True if the call completed without raising an exception. - was_clean: True if the response indicates no modification (clean scan). - was_virus: True if the response indicates virus detection. - - Example - Basic inspection: - >>> client = MockIcapClient() - >>> client.scan_bytes(b"test", filename="test.txt") - >>> call = client.calls[0] - >>> call.method - 'scan_bytes' - >>> call.data - b'test' - >>> call.filename - 'test.txt' - >>> call.was_clean - True - >>> call.matched_by - 'default' - - Example - Checking virus detection: - >>> client.on_respmod(IcapResponseBuilder().virus("Trojan").build()) - >>> client.scan_bytes(b"malware") - >>> call = client.last_call - >>> call.was_virus - True - >>> call.response.headers["X-Virus-ID"] - 'Trojan' - - Example - Exception tracking: - >>> client.on_respmod(raises=IcapTimeoutError("Timeout")) - >>> try: - ... client.scan_bytes(b"data") - ... except IcapTimeoutError: - ... pass - >>> call = client.calls[-1] - >>> call.succeeded - False - >>> type(call.exception).__name__ - 'IcapTimeoutError' - """ - - method: str - timestamp: float - kwargs: dict[str, Any] = field(default_factory=dict) - - # Track response/exception and how it was determined - response: IcapResponse | None = None - exception: Exception | None = None - matched_by: str | None = None # "matcher", "callback", "queue", or "default" - call_index: int = 0 - - # === Convenience Properties === - - @property - def data(self) -> bytes | None: - """ - Get the scanned data if this was a scan call. - - Returns: - The bytes that were scanned, or None if not a scan call. - """ - return self.kwargs.get("data") - - @property - def filename(self) -> str | None: - """ - Get the filename if provided to the call. - - Returns: - The filename argument, or None if not provided. - """ - return self.kwargs.get("filename") - - @property - def service(self) -> str | None: - """ - Get the service name used in the call. - - Returns: - The service name (e.g., "avscan"), or None if not provided. - """ - return self.kwargs.get("service") - - @property - def succeeded(self) -> bool: - """ - Check if the call completed successfully (didn't raise an exception). - - Returns: - True if the call returned a response, False if it raised an exception. - """ - return self.exception is None - - @property - def was_clean(self) -> bool: - """ - Check if the call resulted in a clean (no modification) response. - - Returns: - True if the call succeeded and returned a 204 No Modification response. - False if an exception was raised or the response indicates modification. - """ - return ( - self.exception is None - and self.response is not None - and self.response.is_no_modification - ) - - @property - def was_virus(self) -> bool: - """ - Check if the call resulted in a virus detection. - - Returns: - True if the call succeeded and the response contains an X-Virus-ID header. - False if an exception was raised, response is clean, or no virus header present. - """ - return ( - self.exception is None - and self.response is not None - and not self.response.is_no_modification - and "X-Virus-ID" in self.response.headers - ) - - def __repr__(self) -> str: - """ - Return a rich string representation for debugging. - - Format: method(data=..., filename=...) -> result - Where result is one of: clean, virus(name), raised ExceptionType - """ - parts = [f"{self.method}("] - - # Show truncated data if present - if self.data is not None: - if len(self.data) > 20: - parts.append(f"data={self.data[:20]!r}...") - else: - parts.append(f"data={self.data!r}") - - # Show filename if present - if self.filename: - if self.data is not None: - parts.append(f", filename={self.filename!r}") - else: - parts.append(f"filename={self.filename!r}") - - # Show service if different from default - if self.service and self.service != "avscan": - if self.data is not None or self.filename: - parts.append(f", service={self.service!r}") - else: - parts.append(f"service={self.service!r}") - - parts.append(")") - - # Show result - if self.was_clean: - parts.append(" -> clean") - elif self.was_virus: - # was_virus property guarantees self.response is not None - assert self.response is not None - virus_id = self.response.headers.get("X-Virus-ID", "unknown") - parts.append(f" -> virus({virus_id})") - elif self.exception: - parts.append(f" -> raised {type(self.exception).__name__}") - elif self.response: - parts.append(f" -> {self.response.status_code}") - - return "".join(parts) - - -class MockIcapClient: - """ - Mock ICAP client for testing without network I/O. - - Implements the full IcapClient interface with configurable responses - and call recording for assertions. By default, all methods return clean/success - responses (204 No Modification for scans, 200 OK for OPTIONS). - - The mock provides six main capabilities: - - 1. **Response Configuration**: Set what responses methods should return. - 2. **Response Sequences**: Queue multiple responses consumed in order. - 3. **Dynamic Callbacks**: Generate responses based on request content. - 4. **Content Matchers**: Declarative rules matching filename, service, or data. - 5. **Call Recording**: Track all calls with rich inspection and filtering. - 6. **Strict Mode**: Validate all configured responses were consumed. - - Response Resolution Order: - When a method is called, the mock determines the response in this order: - 1. **Matchers** - First matching rule wins (via when().respond()) - 2. **Callbacks** - If defined for the method (via on_respmod(callback=...)) - 3. **Queue** - Next queued response if available (via on_respmod(r1, r2, r3)) - 4. **Default** - Single configured response (via on_respmod(response)) - - Attributes: - host: Mock server hostname (default: "mock-icap-server"). - port: Mock server port (default: 1344). - is_connected: Whether connect() has been called. - calls: List of MockCall objects recording all method invocations. - call_count: Total number of calls made. - first_call: First call made (or None if no calls). - last_call: Most recent call (or None if no calls). - last_scan_call: Most recent scan_bytes/scan_file/scan_stream call. - - Configuration Methods: - on_options(*responses, raises, callback): Configure OPTIONS responses. - on_respmod(*responses, raises, callback): Configure scan method responses. - on_reqmod(*responses, raises, callback): Configure REQMOD responses. - on_any(response, raises): Configure all methods at once. - when(service, filename, filename_matches, data_contains): Create matchers. - reset_responses(): Clear all configured responses and matchers. - - Assertion Methods: - assert_called(method, times): Assert method was called N times. - assert_not_called(method): Assert method was never called. - assert_scanned(data): Assert specific bytes were scanned. - assert_called_with(method, **kwargs): Assert last call had specific args. - assert_any_call(method, **kwargs): Assert any call had specific args. - assert_called_in_order(methods): Assert methods called in sequence. - assert_scanned_file(filepath): Assert specific file was scanned. - assert_scanned_with_filename(filename): Assert filename was used. - assert_all_responses_used(): Validate all responses consumed (strict mode). - reset_calls(): Clear call history. - - Query Methods: - get_calls(method): Filter calls by method name. - get_scan_calls(): Get all scan_bytes/scan_file/scan_stream calls. - call_counts_by_method: Dict of method name to call count. - - Example - Basic usage: - >>> client = MockIcapClient() - >>> response = client.scan_bytes(b"safe content") - >>> assert response.is_no_modification # Default is clean - >>> client.assert_called("scan_bytes", times=1) - - Example - Response sequence (consumed in order): - >>> client = MockIcapClient() - >>> client.on_respmod( - ... IcapResponseBuilder().clean().build(), - ... IcapResponseBuilder().virus("Trojan").build(), - ... IcapResponseBuilder().clean().build(), - ... ) - >>> client.scan_bytes(b"file1").is_no_modification # True (clean) - >>> client.scan_bytes(b"file2").is_no_modification # False (virus) - >>> client.scan_bytes(b"file3").is_no_modification # True (clean) - - Example - Dynamic callback: - >>> def eicar_detector(data: bytes, **kwargs) -> IcapResponse: - ... if b"EICAR" in data: - ... return IcapResponseBuilder().virus("EICAR-Test").build() - ... return IcapResponseBuilder().clean().build() - >>> client = MockIcapClient() - >>> client.on_respmod(callback=eicar_detector) - >>> client.scan_bytes(b"safe").is_no_modification # True - >>> client.scan_bytes(b"EICAR test").is_no_modification # False - - Example - Content matchers: - >>> client = MockIcapClient() - >>> client.when(filename_matches=r".*\\.exe$").respond( - ... IcapResponseBuilder().virus("Blocked.Exe").build() - ... ) - >>> client.when(data_contains=b"EICAR").respond( - ... IcapResponseBuilder().virus("EICAR-Test").build() - ... ) - >>> client.scan_bytes(b"safe", filename="doc.pdf").is_no_modification # True - >>> client.scan_bytes(b"safe", filename="app.exe").is_no_modification # False - - Example - Exception injection: - >>> from icap.exception import IcapTimeoutError - >>> client = MockIcapClient() - >>> client.on_any(raises=IcapTimeoutError("Connection timed out")) - >>> client.scan_bytes(b"content") # Raises IcapTimeoutError - - Example - Rich call inspection: - >>> client = MockIcapClient() - >>> client.on_respmod(IcapResponseBuilder().virus("Trojan").build()) - >>> client.scan_bytes(b"malware", filename="bad.exe") - >>> call = client.last_call - >>> call.filename # "bad.exe" - >>> call.was_virus # True - >>> call.matched_by # "default" - >>> call.response.headers["X-Virus-ID"] # "Trojan" - - Example - Strict mode (validate all responses used): - >>> client = MockIcapClient(strict=True) - >>> client.on_respmod( - ... IcapResponseBuilder().clean().build(), - ... IcapResponseBuilder().virus().build(), - ... ) - >>> client.scan_bytes(b"file1") - >>> client.scan_bytes(b"file2") - >>> client.assert_all_responses_used() # Passes - all consumed - - See Also: - MockAsyncIcapClient: Async version with same API but awaitable methods. - IcapResponseBuilder: Fluent builder for creating test responses. - MockCall: Dataclass representing a recorded method call. - ResponseMatcher: Dataclass for content-based matching rules. - MatcherBuilder: Fluent API for creating matchers via when(). - """ - - def __init__( - self, - host: str = "mock-icap-server", - port: int = 1344, - *, - strict: bool = False, - ) -> None: - """ - Initialize the mock ICAP client. - - Args: - host: Mock server hostname (default: "mock-icap-server"). - This value is stored but not used for actual connections. - port: Mock server port (default: 1344). - This value is stored but not used for actual connections. - strict: If True, enables strict mode validation. Use - assert_all_responses_used() to verify all configured - responses were consumed. Default: False. - """ - self._host = host - self._port = port - self._connected = False - self._strict = strict - self._calls: list[MockCall] = [] - - # Response queues for sequential responses - self._response_queues: dict[str, deque[IcapResponse | Exception]] = { - "options": deque(), - "respmod": deque(), - "reqmod": deque(), - } - - # Track whether queue mode is active for each method - # When True and queue is empty, raises MockResponseExhaustedError - self._queue_active: dict[str, bool] = { - "options": False, - "respmod": False, - "reqmod": False, - } - - # Track initial queue sizes for strict mode validation - self._initial_queue_sizes: dict[str, int] = { - "options": 0, - "respmod": 0, - "reqmod": 0, - } - - # Track callback usage for strict mode validation - self._callback_used: dict[str, bool] = { - "options": False, - "respmod": False, - "reqmod": False, - } - - # Default responses (clean/success) - used when queue mode is not active - self._options_response: IcapResponse | Exception = IcapResponseBuilder().options().build() - self._respmod_response: IcapResponse | Exception = IcapResponseBuilder().clean().build() - self._reqmod_response: IcapResponse | Exception = IcapResponseBuilder().clean().build() - - # Callbacks for dynamic response generation - self._callbacks: dict[str, ResponseCallback | AsyncResponseCallback | None] = { - "options": None, - "respmod": None, - "reqmod": None, - } - - # Content matchers for declarative conditional responses - self._matchers: list[ResponseMatcher] = [] - - # === Configuration API === - - def on_options( - self, - *responses: IcapResponse | Exception, - raises: Exception | None = None, - ) -> MockIcapClient: - """ - Configure what the OPTIONS method returns. - - Supports three usage patterns: - 1. **Single response**: Pass one response that all calls will return. - 2. **Response sequence**: Pass multiple responses that are consumed - in order. When exhausted, raises MockResponseExhaustedError. - 3. **Exception injection**: Use raises= to make all calls raise. - - Args: - *responses: One or more IcapResponse objects (or Exceptions). - If multiple provided, they form a queue consumed in order. - raises: Exception to raise on all calls. Takes precedence over responses. - - Returns: - Self for method chaining. - - Raises: - MockResponseExhaustedError: When all queued responses are consumed - and another call is made. - - Example - Single response: - >>> client = MockIcapClient() - >>> client.on_options(IcapResponseBuilder().options(methods=["RESPMOD"]).build()) - >>> response = client.options("avscan") - >>> response.headers["Methods"] - 'RESPMOD' - - Example - Response sequence: - >>> client = MockIcapClient() - >>> client.on_options( - ... IcapResponseBuilder().options(methods=["RESPMOD"]).build(), - ... IcapResponseBuilder().error(503, "Service Unavailable").build(), - ... ) - >>> client.options("avscan").is_success # True (first response) - >>> client.options("avscan").is_success # False (503 error) - - Example - Raise exception: - >>> client.on_options(raises=IcapConnectionError("Server unavailable")) - - See Also: - reset_responses: Clear queued responses without clearing call history. - on_respmod: Configure RESPMOD method responses. - on_reqmod: Configure REQMOD method responses. - """ - if raises is not None: - self._response_queues["options"].clear() - self._queue_active["options"] = False - self._initial_queue_sizes["options"] = 0 - self._options_response = raises - elif len(responses) == 1: - self._response_queues["options"].clear() - self._queue_active["options"] = False - self._initial_queue_sizes["options"] = 0 - self._options_response = responses[0] - elif len(responses) > 1: - self._response_queues["options"].clear() - self._response_queues["options"].extend(responses) - self._queue_active["options"] = True - self._initial_queue_sizes["options"] = len(responses) - return self - - def on_respmod( - self, - *responses: IcapResponse | Exception, - raises: Exception | None = None, - callback: ResponseCallback | None = None, - ) -> MockIcapClient: - """ - Configure what RESPMOD and scan methods return. - - This affects respmod(), scan_bytes(), scan_file(), and scan_stream() - since the scan_* methods use RESPMOD internally. - - Supports four usage patterns: - 1. **Single response**: Pass one response that all calls will return. - 2. **Response sequence**: Pass multiple responses that are consumed - in order. When exhausted, raises MockResponseExhaustedError. - 3. **Exception injection**: Use raises= to make all calls raise. - 4. **Callback**: Use callback= for dynamic response generation. - - Args: - *responses: One or more IcapResponse objects (or Exceptions). - If multiple provided, they form a queue consumed in order. - raises: Exception to raise on all calls. Takes precedence over responses. - callback: Function called with (data, service=, filename=, **kwargs) - that returns an IcapResponse. Used for dynamic responses. - Takes precedence over responses and raises. - - Returns: - Self for method chaining. - - Raises: - MockResponseExhaustedError: When all queued responses are consumed - and another call is made. - - Example - Single response (all scans return same result): - >>> client = MockIcapClient() - >>> client.on_respmod(IcapResponseBuilder().virus("Trojan.Gen").build()) - >>> response = client.scan_bytes(b"content") - >>> assert not response.is_no_modification - - Example - Response sequence (consumed in order): - >>> client = MockIcapClient() - >>> client.on_respmod( - ... IcapResponseBuilder().clean().build(), - ... IcapResponseBuilder().virus("Trojan.Gen").build(), - ... ) - >>> client.scan_bytes(b"file1").is_no_modification # True (clean) - >>> client.scan_bytes(b"file2").is_no_modification # False (virus) - - Example - Exception injection: - >>> client.on_respmod(raises=IcapTimeoutError("Scan timed out")) - - Example - Dynamic callback: - >>> def eicar_detector(data: bytes, **kwargs) -> IcapResponse: - ... if b"EICAR" in data: - ... return IcapResponseBuilder().virus("EICAR-Test").build() - ... return IcapResponseBuilder().clean().build() - >>> client = MockIcapClient() - >>> client.on_respmod(callback=eicar_detector) - >>> client.scan_bytes(b"safe").is_no_modification # True - >>> client.scan_bytes(b"X5O!P%@AP...EICAR...").is_no_modification # False - - See Also: - reset_responses: Clear queued responses without clearing call history. - on_options: Configure OPTIONS method responses. - on_reqmod: Configure REQMOD method responses. - ResponseCallback: Protocol defining the callback signature. - """ - if callback is not None: - # Callback mode: clear other configurations - self._response_queues["respmod"].clear() - self._queue_active["respmod"] = False - self._initial_queue_sizes["respmod"] = 0 - self._callback_used["respmod"] = False - self._callbacks["respmod"] = callback - elif raises is not None: - # Clear queue and set default to exception - self._response_queues["respmod"].clear() - self._queue_active["respmod"] = False - self._initial_queue_sizes["respmod"] = 0 - self._callbacks["respmod"] = None - self._respmod_response = raises - elif len(responses) == 1: - # Single response: set as default, clear queue - self._response_queues["respmod"].clear() - self._queue_active["respmod"] = False - self._initial_queue_sizes["respmod"] = 0 - self._callbacks["respmod"] = None - self._respmod_response = responses[0] - elif len(responses) > 1: - # Multiple responses: queue them all - self._response_queues["respmod"].clear() - self._response_queues["respmod"].extend(responses) - self._queue_active["respmod"] = True - self._initial_queue_sizes["respmod"] = len(responses) - self._callbacks["respmod"] = None - return self - - def on_reqmod( - self, - *responses: IcapResponse | Exception, - raises: Exception | None = None, - ) -> MockIcapClient: - """ - Configure what the REQMOD method returns. - - Supports three usage patterns: - 1. **Single response**: Pass one response that all calls will return. - 2. **Response sequence**: Pass multiple responses that are consumed - in order. When exhausted, raises MockResponseExhaustedError. - 3. **Exception injection**: Use raises= to make all calls raise. - - Args: - *responses: One or more IcapResponse objects (or Exceptions). - If multiple provided, they form a queue consumed in order. - raises: Exception to raise on all calls. Takes precedence over responses. - - Returns: - Self for method chaining. - - Raises: - MockResponseExhaustedError: When all queued responses are consumed - and another call is made. - - Example - Single response: - >>> client = MockIcapClient() - >>> client.on_reqmod(IcapResponseBuilder().clean().build()) - >>> http_req = b"POST /upload HTTP/1.1\\r\\nHost: example.com\\r\\n\\r\\n" - >>> response = client.reqmod("avscan", http_req, b"file content") - >>> assert response.is_no_modification - - Example - Response sequence: - >>> client = MockIcapClient() - >>> client.on_reqmod( - ... IcapResponseBuilder().clean().build(), - ... IcapResponseBuilder().error(500).build(), - ... ) - >>> client.reqmod("avscan", http_req, b"file1").is_success # True - >>> client.reqmod("avscan", http_req, b"file2").is_success # False - - See Also: - reset_responses: Clear queued responses without clearing call history. - on_options: Configure OPTIONS method responses. - on_respmod: Configure RESPMOD method responses. - """ - if raises is not None: - self._response_queues["reqmod"].clear() - self._queue_active["reqmod"] = False - self._initial_queue_sizes["reqmod"] = 0 - self._reqmod_response = raises - elif len(responses) == 1: - self._response_queues["reqmod"].clear() - self._queue_active["reqmod"] = False - self._initial_queue_sizes["reqmod"] = 0 - self._reqmod_response = responses[0] - elif len(responses) > 1: - self._response_queues["reqmod"].clear() - self._response_queues["reqmod"].extend(responses) - self._queue_active["reqmod"] = True - self._initial_queue_sizes["reqmod"] = len(responses) - return self - - def on_any( - self, - response: IcapResponse | None = None, - *, - raises: Exception | None = None, - ) -> MockIcapClient: - """ - Configure all methods (OPTIONS, RESPMOD, REQMOD) at once. - - Convenience method to set the same response or exception for all methods. - Note: This sets a single response for all methods, not a sequence. - - Args: - response: IcapResponse to return from all methods. - raises: Exception to raise from all methods. Takes precedence. - - Returns: - Self for method chaining. - - Example - All methods return clean: - >>> client = MockIcapClient() - >>> client.on_any(IcapResponseBuilder().clean().build()) - - Example - All methods fail: - >>> client.on_any(raises=IcapConnectionError("Server down")) - """ - if raises is not None: - self.on_options(raises=raises) - self.on_respmod(raises=raises) - self.on_reqmod(raises=raises) - elif response is not None: - self.on_options(response) - self.on_respmod(response) - self.on_reqmod(response) - return self - - def reset_responses(self) -> None: - """ - Clear all response queues and reset to defaults. - - This clears any queued responses but does NOT clear call history. - Use reset_calls() to clear call history. - - After calling reset_responses(), the mock returns default responses: - - OPTIONS: 200 OK with standard server capabilities - - RESPMOD/scan_*: 204 No Modification (clean) - - REQMOD: 204 No Modification (clean) - - Example: - >>> client = MockIcapClient() - >>> client.on_respmod( - ... IcapResponseBuilder().virus().build(), - ... IcapResponseBuilder().virus().build(), - ... ) - >>> client.scan_bytes(b"file1") # Returns virus - >>> client.reset_responses() - >>> client.scan_bytes(b"file2") # Returns clean (default) - >>> len(client.calls) # 2 - call history preserved - 2 - - See Also: - reset_calls: Clear call history without resetting responses. - """ - for queue in self._response_queues.values(): - queue.clear() - - for method in self._queue_active: - self._queue_active[method] = False - - self._options_response = IcapResponseBuilder().options().build() - self._respmod_response = IcapResponseBuilder().clean().build() - self._reqmod_response = IcapResponseBuilder().clean().build() - - for method in self._callbacks: - self._callbacks[method] = None - - self._matchers.clear() - - def when( - self, - *, - service: str | None = None, - filename: str | None = None, - filename_matches: str | None = None, - data_contains: bytes | None = None, - ) -> MatcherBuilder: - """ - Create a conditional response matcher. - - Returns a MatcherBuilder that collects match criteria and registers - the matcher when respond() is called. Matchers are checked in registration - order; the first match wins. Matchers take highest priority in response - resolution (before callbacks, queues, and defaults). - - Args: - service: Exact service name to match (e.g., "avscan"). - filename: Exact filename to match (e.g., "malware.exe"). - filename_matches: Regex pattern string to match against filename. - data_contains: Bytes that must be present in scanned content. - - Returns: - MatcherBuilder for configuring the response. - - Example - Filename matching: - >>> client = MockIcapClient() - >>> client.when(filename="malware.exe").respond( - ... IcapResponseBuilder().virus("Known.Malware").build() - ... ) - >>> client.scan_bytes(b"content", filename="malware.exe").is_no_modification - False # virus detected - >>> client.scan_bytes(b"content", filename="safe.txt").is_no_modification - True # falls through to default - - Example - Pattern matching: - >>> client.when(filename_matches=r".*\\.exe$").respond( - ... IcapResponseBuilder().virus("Policy.BlockedExecutable").build() - ... ) - - Example - Content matching: - >>> client.when(data_contains=b"EICAR").respond( - ... IcapResponseBuilder().virus("EICAR-Test").build() - ... ) - - Example - Combined criteria: - >>> client.when(service="avscan", data_contains=b"malicious").respond( - ... IcapResponseBuilder().virus().build() - ... ) - - Example - Multiple matchers with chaining: - >>> client.when(filename="virus.exe").respond( - ... IcapResponseBuilder().virus().build() - ... ).when(filename="clean.txt").respond( - ... IcapResponseBuilder().clean().build() - ... ) - - Example - Limited use matcher: - >>> client.when(data_contains=b"bad").respond( - ... IcapResponseBuilder().virus().build(), - ... times=2, # Only match first 2 times - ... ) - - See Also: - MatcherBuilder: Builder returned by this method. - ResponseMatcher: The underlying matcher dataclass. - reset_responses: Clears all matchers along with other configurations. - """ - return MatcherBuilder( - self, - service=service, - filename=filename, - filename_matches=filename_matches, - data_contains=data_contains, - ) - - # === Assertion API === - - @property - def calls(self) -> list[MockCall]: - """ - Get a copy of all recorded method calls. - - Returns a copy to prevent accidental modification. Each MockCall - contains the method name, timestamp, and keyword arguments. - - Returns: - List of MockCall objects in chronological order. - - Example: - >>> client = MockIcapClient() - >>> client.scan_bytes(b"test1") - >>> client.scan_bytes(b"test2") - >>> len(client.calls) - 2 - >>> client.calls[0].kwargs["data"] - b'test1' - """ - return self._calls.copy() - - @property - def first_call(self) -> MockCall | None: - """ - Get the first recorded call, or None if no calls were made. - - Returns: - The first MockCall, or None if calls list is empty. - - Example: - >>> client = MockIcapClient() - >>> client.first_call # None - >>> client.scan_bytes(b"first") - >>> client.scan_bytes(b"second") - >>> client.first_call.data - b'first' - """ - return self._calls[0] if self._calls else None - - @property - def last_call(self) -> MockCall | None: - """ - Get the most recent call, or None if no calls were made. - - Returns: - The last MockCall, or None if calls list is empty. - - Example: - >>> client = MockIcapClient() - >>> client.last_call # None - >>> client.scan_bytes(b"first") - >>> client.scan_bytes(b"second") - >>> client.last_call.data - b'second' - """ - return self._calls[-1] if self._calls else None - - @property - def last_scan_call(self) -> MockCall | None: - """ - Get the most recent scan call (scan_bytes, scan_file, scan_stream). - - Returns: - The last scan-related MockCall, or None if no scan calls were made. - - Example: - >>> client = MockIcapClient() - >>> client.options("avscan") # Not a scan - >>> client.scan_bytes(b"test") # This is a scan - >>> client.options("avscan") # Not a scan - >>> client.last_scan_call.method - 'scan_bytes' - >>> client.last_scan_call.data - b'test' - """ - scan_methods = {"scan_bytes", "scan_file", "scan_stream"} - for call in reversed(self._calls): - if call.method in scan_methods: - return call - return None - - def get_calls(self, method: str | None = None) -> list[MockCall]: - """ - Get calls, optionally filtered by method name. - - Args: - method: If provided, only return calls with this method name. - If None, returns all calls. - - Returns: - List of matching MockCall objects in chronological order. - - Example: - >>> client = MockIcapClient() - >>> client.options("avscan") - >>> client.scan_bytes(b"file1") - >>> client.scan_bytes(b"file2") - >>> len(client.get_calls()) # All calls - 3 - >>> len(client.get_calls("scan_bytes")) # Only scan_bytes - 2 - >>> [c.data for c in client.get_calls("scan_bytes")] - [b'file1', b'file2'] - """ - if method is None: - return self._calls.copy() - return [c for c in self._calls if c.method == method] - - def get_scan_calls(self) -> list[MockCall]: - """ - Get all scan-related calls (scan_bytes, scan_file, scan_stream). - - Convenience method for filtering to only scan operations, excluding - lower-level calls like options, respmod, and reqmod. - - Returns: - List of scan MockCall objects in chronological order. - - Example: - >>> client = MockIcapClient() - >>> client.options("avscan") - >>> client.scan_bytes(b"file1") - >>> client.scan_file("/path/to/file.txt") - >>> len(client.get_scan_calls()) - 2 - >>> [c.method for c in client.get_scan_calls()] - ['scan_bytes', 'scan_file'] - """ - scan_methods = {"scan_bytes", "scan_file", "scan_stream"} - return [c for c in self._calls if c.method in scan_methods] - - @property - def call_count(self) -> int: - """ - Get the total number of calls made. - - Returns: - The number of recorded method calls. - - Example: - >>> client = MockIcapClient() - >>> client.call_count - 0 - >>> client.scan_bytes(b"test") - >>> client.options("avscan") - >>> client.call_count - 2 - """ - return len(self._calls) - - @property - def call_counts_by_method(self) -> dict[str, int]: - """ - Get call counts grouped by method name. - - Returns: - Dictionary mapping method names to their call counts. - - Example: - >>> client = MockIcapClient() - >>> client.scan_bytes(b"file1") - >>> client.scan_bytes(b"file2") - >>> client.options("avscan") - >>> client.call_counts_by_method - {'scan_bytes': 2, 'options': 1} - """ - counts: dict[str, int] = {} - for call in self._calls: - counts[call.method] = counts.get(call.method, 0) + 1 - return counts - - def assert_called(self, method: str, *, times: int | None = None) -> None: - """ - Assert that a method was called. - - Args: - method: Method name to check (e.g., "scan_bytes", "options", "respmod"). - times: If provided, assert the method was called exactly this many times. - - Raises: - AssertionError: If the method was never called, or was called a - different number of times than expected. - - Example: - >>> client = MockIcapClient() - >>> client.scan_bytes(b"content") - >>> client.assert_called("scan_bytes") # Passes - >>> client.assert_called("scan_bytes", times=1) # Passes - >>> client.assert_called("scan_bytes", times=2) # Raises AssertionError - >>> client.assert_called("options") # Raises AssertionError - """ - matching = [c for c in self._calls if c.method == method] - if not matching: - raise AssertionError(f"Method '{method}' was never called") - if times is not None and len(matching) != times: - raise AssertionError( - f"Method '{method}' was called {len(matching)} times, expected {times}" - ) - - def assert_not_called(self, method: str | None = None) -> None: - """ - Assert that a method (or any method) was not called. - - Args: - method: Specific method name to check. If None, asserts no methods - were called at all. - - Raises: - AssertionError: If the method (or any method) was called. - - Example: - >>> client = MockIcapClient() - >>> client.assert_not_called() # Passes - nothing called yet - >>> client.assert_not_called("options") # Passes - >>> client.scan_bytes(b"test") - >>> client.assert_not_called("options") # Still passes - >>> client.assert_not_called("scan_bytes") # Raises AssertionError - >>> client.assert_not_called() # Raises AssertionError - """ - if method is None: - if self._calls: - raise AssertionError(f"Expected no calls, got: {self._calls}") - else: - matching = [c for c in self._calls if c.method == method] - if matching: - raise AssertionError(f"Method '{method}' was called {len(matching)} times") - - def assert_scanned(self, data: bytes) -> None: - """ - Assert that specific content was scanned. - - Checks if the given bytes were passed to scan_bytes() or respmod(). - For respmod(), checks if the http_response ends with the given data. - - Args: - data: The exact bytes that should have been scanned. - - Raises: - AssertionError: If the data was not found in any scan call. - - Example: - >>> client = MockIcapClient() - >>> client.scan_bytes(b"test content") - >>> client.assert_scanned(b"test content") # Passes - >>> client.assert_scanned(b"other") # Raises AssertionError - """ - for call in self._calls: - if call.method in ("scan_bytes", "respmod"): - if call.kwargs.get("data") == data: - return - if call.kwargs.get("http_response", b"").endswith(data): - return - raise AssertionError(f"Content {data!r} was not scanned") - - def reset_calls(self) -> None: - """ - Clear the call history. - - Use this between test phases to reset assertions. - - Example: - >>> client = MockIcapClient() - >>> client.scan_bytes(b"test1") - >>> client.assert_called("scan_bytes", times=1) - >>> client.reset_calls() - >>> client.assert_not_called() # Passes - history cleared - >>> client.scan_bytes(b"test2") - >>> client.assert_called("scan_bytes", times=1) # Only counts new call - """ - self._calls.clear() - - def assert_called_with(self, method: str, **kwargs: Any) -> None: - """ - Assert that a method was called with specific arguments. - - Finds the most recent call to the method and checks that the provided - kwargs match the call's arguments. Only the provided kwargs are checked; - additional arguments in the call are allowed. - - Args: - method: Method name to check. - **kwargs: Expected keyword arguments (partial match). - - Raises: - AssertionError: If the method was never called, or the most recent - call doesn't match the expected kwargs. - - Example: - >>> client = MockIcapClient() - >>> client.scan_bytes(b"content", filename="test.txt", service="avscan") - >>> client.assert_called_with("scan_bytes", data=b"content") # Passes - >>> client.assert_called_with("scan_bytes", filename="test.txt") # Passes - >>> client.assert_called_with("scan_bytes", filename="other.txt") # Fails - """ - matching = [c for c in self._calls if c.method == method] - if not matching: - raise AssertionError(f"Method '{method}' was never called") - - last_call = matching[-1] - for key, expected_value in kwargs.items(): - actual_value = last_call.kwargs.get(key) - if actual_value != expected_value: - raise AssertionError( - f"Method '{method}' called with {key}={actual_value!r}, " - f"expected {key}={expected_value!r}" - ) - - def assert_any_call(self, method: str, **kwargs: Any) -> None: - """ - Assert that at least one call matches the specified arguments. - - Searches all calls to the method for one that matches all provided kwargs. - Unlike assert_called_with, this doesn't require the match to be the most - recent call. - - Args: - method: Method name to check. - **kwargs: Expected keyword arguments (partial match). - - Raises: - AssertionError: If no call matches the expected kwargs. - - Example: - >>> client = MockIcapClient() - >>> client.scan_bytes(b"first", filename="a.txt") - >>> client.scan_bytes(b"second", filename="b.txt") - >>> client.scan_bytes(b"third", filename="c.txt") - >>> client.assert_any_call("scan_bytes", filename="b.txt") # Passes - >>> client.assert_any_call("scan_bytes", filename="z.txt") # Fails - """ - matching = [c for c in self._calls if c.method == method] - if not matching: - raise AssertionError(f"Method '{method}' was never called") - - for call in matching: - if all(call.kwargs.get(k) == v for k, v in kwargs.items()): - return # Found a match - - raise AssertionError( - f"No call to '{method}' matched kwargs {kwargs!r}. " - f"Actual calls: {[c.kwargs for c in matching]}" - ) - - def assert_called_in_order(self, methods: list[str]) -> None: - """ - Assert that methods were called in the specified order. - - Checks that the call history contains the methods in the given order. - Other calls may appear between the specified methods. - - Args: - methods: List of method names in expected order. - - Raises: - AssertionError: If the methods weren't called in order. - - Example: - >>> client = MockIcapClient() - >>> client.options("avscan") - >>> client.scan_bytes(b"test") - >>> client.assert_called_in_order(["options", "scan_bytes"]) # Passes - >>> client.assert_called_in_order(["scan_bytes", "options"]) # Fails - """ - if not methods: - return - - actual_methods = [c.method for c in self._calls] - method_index = 0 - - for actual in actual_methods: - if method_index < len(methods) and actual == methods[method_index]: - method_index += 1 - - if method_index != len(methods): - missing = methods[method_index:] - raise AssertionError( - f"Methods not called in expected order. " - f"Expected: {methods}, Actual order: {actual_methods}. " - f"Missing or out of order: {missing}" - ) - - def assert_scanned_file(self, filepath: str) -> None: - """ - Assert that a specific file path was scanned. - - Checks if scan_file() was called with the given filepath. - - Args: - filepath: The file path that should have been scanned. - - Raises: - AssertionError: If the file was not scanned. - - Example: - >>> client = MockIcapClient() - >>> client.scan_file("/path/to/file.txt") - >>> client.assert_scanned_file("/path/to/file.txt") # Passes - >>> client.assert_scanned_file("/other/file.txt") # Fails - """ - for call in self._calls: - if call.method == "scan_file" and call.kwargs.get("filepath") == filepath: - return - raise AssertionError(f"File '{filepath}' was not scanned") - - def assert_scanned_with_filename(self, filename: str) -> None: - """ - Assert that a scan was made with a specific filename argument. - - Checks scan_bytes and scan_stream calls for the given filename. - Note: This checks the filename argument, not the filepath in scan_file(). - - Args: - filename: The filename argument that should have been used. - - Raises: - AssertionError: If no scan was made with that filename. - - Example: - >>> client = MockIcapClient() - >>> client.scan_bytes(b"content", filename="report.pdf") - >>> client.assert_scanned_with_filename("report.pdf") # Passes - >>> client.assert_scanned_with_filename("other.pdf") # Fails - """ - for call in self._calls: - if call.method in ("scan_bytes", "scan_stream"): - if call.kwargs.get("filename") == filename: - return - raise AssertionError(f"No scan was made with filename '{filename}'") - - def assert_all_responses_used(self) -> None: - """ - Assert that all configured responses were consumed (strict mode validation). - - This method verifies that: - 1. All queued responses were consumed (queue is empty) - 2. All configured callbacks were invoked at least once - 3. All registered matchers were triggered at least once - - This is useful for ensuring that test setup matches test behavior - - if you configure specific responses, they should all be used. - - Raises: - AssertionError: If any configured responses, callbacks, or matchers - were not used during the test. - - Example - All queued responses consumed: - >>> client = MockIcapClient(strict=True) - >>> client.on_respmod( - ... IcapResponseBuilder().clean().build(), - ... IcapResponseBuilder().virus().build(), - ... ) - >>> client.scan_bytes(b"file1") - >>> client.scan_bytes(b"file2") - >>> client.assert_all_responses_used() # Passes - - Example - Unused responses fail: - >>> client = MockIcapClient(strict=True) - >>> client.on_respmod( - ... IcapResponseBuilder().clean().build(), - ... IcapResponseBuilder().virus().build(), - ... ) - >>> client.scan_bytes(b"file1") # Only consume first response - >>> client.assert_all_responses_used() # Raises AssertionError - - Example - Unused callback fails: - >>> client = MockIcapClient(strict=True) - >>> client.on_respmod(callback=lambda **kwargs: IcapResponseBuilder().clean().build()) - >>> client.assert_all_responses_used() # Raises AssertionError (callback never called) - - Example - Unused matcher fails: - >>> client = MockIcapClient(strict=True) - >>> client.when(filename="malware.exe").respond( - ... IcapResponseBuilder().virus().build() - ... ) - >>> client.scan_bytes(b"content", filename="safe.txt") # Matcher not triggered - >>> client.assert_all_responses_used() # Raises AssertionError - - See Also: - strict: Constructor parameter to enable strict mode. - """ - errors: list[str] = [] - - for method, queue in self._response_queues.items(): - initial_size = self._initial_queue_sizes[method] - remaining = len(queue) - if remaining > 0: - consumed = initial_size - remaining - errors.append( - f"{method}: {remaining} of {initial_size} queued responses not consumed " - f"(consumed {consumed})" - ) - - for method, callback in self._callbacks.items(): - if callback is not None and not self._callback_used[method]: - errors.append(f"{method}: callback was configured but never invoked") - - for i, matcher in enumerate(self._matchers): - if matcher._match_count == 0: - criteria = [] - if matcher.service: - criteria.append(f"service={matcher.service!r}") - if matcher.filename: - criteria.append(f"filename={matcher.filename!r}") - if matcher.filename_pattern: - criteria.append(f"filename_pattern={matcher.filename_pattern.pattern!r}") - if matcher.data_contains: - criteria.append(f"data_contains={matcher.data_contains!r}") - criteria_str = ", ".join(criteria) if criteria else "no criteria" - errors.append(f"matcher[{i}] ({criteria_str}): never matched") - - if errors: - raise AssertionError( - "Not all configured responses were used:\n - " + "\n - ".join(errors) - ) - - # === IcapClient Interface === - - @property - def host(self) -> str: - return self._host - - @property - def port(self) -> int: - return self._port - - @port.setter - def port(self, value: int) -> None: - if not isinstance(value, int): - raise TypeError("Port is not a valid type. Please enter an int value.") - self._port = value - - @property - def is_connected(self) -> bool: - return self._connected - - def connect(self) -> None: - """Simulate connection (no-op).""" - self._connected = True - - def disconnect(self) -> None: - """Simulate disconnection (no-op).""" - self._connected = False - - def __enter__(self) -> MockIcapClient: - self.connect() - return self - - def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> bool: - self.disconnect() - return False - - def _record_call(self, method: str, **kwargs: Any) -> MockCall: - """ - Record a method call and return the MockCall object. - - Args: - method: Name of the method being called. - **kwargs: Arguments passed to the method. - - Returns: - The newly created MockCall object (also appended to self._calls). - """ - call = MockCall( - method=method, - timestamp=time.time(), - kwargs=kwargs, - call_index=len(self._calls), - ) - self._calls.append(call) - return call - - def _get_response_with_metadata( - self, method: str, call_kwargs: dict[str, Any] - ) -> tuple[IcapResponse | Exception, str]: - """ - Get the next response and metadata for the given method. - - This method determines the response and tracks how it was resolved - (matcher, callback, queue, or default). - - Args: - method: The ICAP method name ("options", "respmod", "reqmod"). - call_kwargs: The arguments passed to the method call. - - Returns: - A tuple of (response_or_exception, matched_by) where: - - response_or_exception: The IcapResponse or Exception to return/raise - - matched_by: String indicating resolution source: "matcher", "callback", - "queue", or "default" - - Raises: - MockResponseExhaustedError: When queue is exhausted and queue_active is True. - """ - # Check matchers first (highest priority) - for matcher in self._matchers: - if not matcher.is_exhausted() and matcher.matches(**call_kwargs): - return matcher.consume(), "matcher" - - callback = self._callbacks.get(method) - if callback is not None: - self._callback_used[method] = True - # Type checker can't narrow callback type; sync client only uses sync callbacks - return callback(**call_kwargs), "callback" # type: ignore[return-value] - - queue = self._response_queues[method] - - if queue: - response_or_exception = queue.popleft() - return response_or_exception, "queue" - - if self._queue_active[method]: - raise MockResponseExhaustedError( - f"All queued {method} responses have been consumed. " - f"Configure more responses with on_{method}() or use reset_responses()." - ) - - default_responses: dict[str, IcapResponse | Exception] = { - "options": self._options_response, - "respmod": self._respmod_response, - "reqmod": self._reqmod_response, - } - return default_responses[method], "default" - - def _execute_call(self, call: MockCall, response_method: str) -> IcapResponse: - """ - Execute a recorded call and update it with response metadata. - - This method: - 1. Resolves the response using _get_response_with_metadata - 2. Updates the MockCall with response/exception/matched_by - 3. Returns the response or raises the exception - - Args: - call: The MockCall object to update. - response_method: The method key for response lookup ("options", "respmod", "reqmod"). - - Returns: - The IcapResponse. - - Raises: - Exception: If the response was configured as an exception. - """ - try: - response_or_exception, matched_by = self._get_response_with_metadata( - response_method, call.kwargs - ) - call.matched_by = matched_by - - if isinstance(response_or_exception, Exception): - call.exception = response_or_exception - raise response_or_exception - - call.response = response_or_exception - return response_or_exception - - except MockResponseExhaustedError: - # MockResponseExhaustedError is a configuration error, not a mock response - # Still record that it happened - call.matched_by = "queue" - raise - - def options(self, service: str) -> IcapResponse: - """Send OPTIONS request (mocked).""" - call = self._record_call("options", service=service) - return self._execute_call(call, "options") - - def respmod( - self, - service: str, - http_request: bytes, - http_response: bytes, - headers: dict[str, str] | None = None, - preview: int | None = None, - ) -> IcapResponse: - """Send RESPMOD request (mocked).""" - call = self._record_call( - "respmod", - service=service, - http_request=http_request, - http_response=http_response, - headers=headers, - preview=preview, - ) - return self._execute_call(call, "respmod") - - def reqmod( - self, - service: str, - http_request: bytes, - http_body: bytes | None = None, - headers: dict[str, str] | None = None, - ) -> IcapResponse: - """Send REQMOD request (mocked).""" - call = self._record_call( - "reqmod", - service=service, - http_request=http_request, - http_body=http_body, - headers=headers, - ) - return self._execute_call(call, "reqmod") - - def scan_bytes( - self, - data: bytes, - service: str = "avscan", - filename: str | None = None, - ) -> IcapResponse: - """Scan bytes content (mocked).""" - call = self._record_call( - "scan_bytes", - data=data, - service=service, - filename=filename, - ) - return self._execute_call(call, "respmod") - - def scan_file( - self, - filepath: str | Path, - service: str = "avscan", - ) -> IcapResponse: - """Scan a file (mocked - actually reads the file).""" - filepath = Path(filepath) - if not filepath.exists(): - raise FileNotFoundError(f"File not found: {filepath}") - - data = filepath.read_bytes() - call = self._record_call( - "scan_file", - filepath=str(filepath), - service=service, - data=data, - ) - return self._execute_call(call, "respmod") - - def scan_stream( - self, - stream: BinaryIO, - service: str = "avscan", - filename: str | None = None, - chunk_size: int = 0, - ) -> IcapResponse: - """Scan a stream (mocked - actually reads the stream).""" - data = stream.read() - call = self._record_call( - "scan_stream", - data=data, - service=service, - filename=filename, - chunk_size=chunk_size, - ) - return self._execute_call(call, "respmod") - - -class MockAsyncIcapClient(MockIcapClient): - """ - Async mock ICAP client for testing without network I/O. - - Inherits from MockIcapClient and provides the same functionality with - async/await syntax. All ICAP methods (options, respmod, reqmod, scan_*) - are coroutines that must be awaited. - - The configuration API (on_options, on_respmod, on_reqmod, on_any) and - assertion API (assert_called, assert_not_called, assert_scanned, reset_calls) - are synchronous and inherited directly from MockIcapClient. - - Attributes: - host: Mock server hostname (inherited from MockIcapClient). - port: Mock server port (inherited from MockIcapClient). - is_connected: Whether connect() has been called. - calls: List of MockCall objects recording all method invocations. - - Example - Basic async usage: - >>> async def test_scan(mock_async_icap_client): - ... async with mock_async_icap_client as client: - ... response = await client.scan_bytes(b"content") - ... assert response.is_no_modification - ... client.assert_called("scan_bytes", times=1) - - Example - Configure virus detection: - >>> async def test_virus(mock_async_icap_client): - ... mock_async_icap_client.on_respmod( - ... IcapResponseBuilder().virus("Trojan.Gen").build() - ... ) - ... async with mock_async_icap_client as client: - ... response = await client.scan_file("/path/to/file.txt") - ... assert not response.is_no_modification - - Example - Error handling: - >>> async def test_timeout(mock_async_icap_client): - ... mock_async_icap_client.on_any(raises=IcapTimeoutError("Timeout")) - ... async with mock_async_icap_client as client: - ... with pytest.raises(IcapTimeoutError): - ... await client.scan_bytes(b"content") - - See Also: - MockIcapClient: Synchronous version with full API documentation. - IcapResponseBuilder: Fluent builder for creating test responses. - """ - - async def _get_response_with_metadata_async( - self, method: str, call_kwargs: dict[str, Any] - ) -> tuple[IcapResponse | Exception, str]: - """ - Get the next response and metadata for the given method (async version). - - This method determines the response and tracks how it was resolved - (matcher, callback, queue, or default). Supports both sync and async callbacks. - - Args: - method: The ICAP method name ("options", "respmod", "reqmod"). - call_kwargs: The arguments passed to the method call. - - Returns: - A tuple of (response_or_exception, matched_by) where: - - response_or_exception: The IcapResponse or Exception to return/raise - - matched_by: String indicating resolution source: "matcher", "callback", - "queue", or "default" - - Raises: - MockResponseExhaustedError: When queue is exhausted and queue_active is True. - """ - # Check matchers first (highest priority) - for matcher in self._matchers: - if not matcher.is_exhausted() and matcher.matches(**call_kwargs): - return matcher.consume(), "matcher" - - callback = self._callbacks.get(method) - if callback is not None: - self._callback_used[method] = True - # Check if callback is async and await if needed - if inspect.iscoroutinefunction(callback): - async_callback = cast(AsyncResponseCallback, callback) - result = await async_callback(**call_kwargs) - else: - sync_callback = cast(ResponseCallback, callback) - result = sync_callback(**call_kwargs) - return result, "callback" - - queue = self._response_queues[method] - - if queue: - response_or_exception = queue.popleft() - return response_or_exception, "queue" - - if self._queue_active[method]: - raise MockResponseExhaustedError( - f"All queued {method} responses have been consumed. " - f"Configure more responses with on_{method}() or use reset_responses()." - ) - - default_responses: dict[str, IcapResponse | Exception] = { - "options": self._options_response, - "respmod": self._respmod_response, - "reqmod": self._reqmod_response, - } - return default_responses[method], "default" - - async def _execute_call_async(self, call: MockCall, response_method: str) -> IcapResponse: - """ - Execute a recorded call and update it with response metadata (async version). - - This method: - 1. Resolves the response using _get_response_with_metadata_async - 2. Updates the MockCall with response/exception/matched_by - 3. Returns the response or raises the exception - - Args: - call: The MockCall object to update. - response_method: The method key for response lookup ("options", "respmod", "reqmod"). - - Returns: - The IcapResponse. - - Raises: - Exception: If the response was configured as an exception. - """ - try: - response_or_exception, matched_by = await self._get_response_with_metadata_async( - response_method, call.kwargs - ) - call.matched_by = matched_by - - if isinstance(response_or_exception, Exception): - call.exception = response_or_exception - raise response_or_exception - - call.response = response_or_exception - return response_or_exception - - except MockResponseExhaustedError: - # MockResponseExhaustedError is a configuration error, not a mock response - # Still record that it happened - call.matched_by = "queue" - raise - - async def connect(self) -> None: # type: ignore[override] - """Simulate async connection (no-op).""" - self._connected = True - - async def disconnect(self) -> None: # type: ignore[override] - """Simulate async disconnection (no-op).""" - self._connected = False - - async def __aenter__(self) -> MockAsyncIcapClient: - await self.connect() - return self - - async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> bool: - await self.disconnect() - return False - - async def options(self, service: str) -> IcapResponse: # type: ignore[override] - """Send OPTIONS request (mocked).""" - call = self._record_call("options", service=service) - return await self._execute_call_async(call, "options") - - async def respmod( # type: ignore[override] - self, - service: str, - http_request: bytes, - http_response: bytes, - headers: dict[str, str] | None = None, - preview: int | None = None, - ) -> IcapResponse: - """Send RESPMOD request (mocked).""" - call = self._record_call( - "respmod", - service=service, - http_request=http_request, - http_response=http_response, - headers=headers, - preview=preview, - ) - return await self._execute_call_async(call, "respmod") - - async def reqmod( # type: ignore[override] - self, - service: str, - http_request: bytes, - http_body: bytes | None = None, - headers: dict[str, str] | None = None, - ) -> IcapResponse: - """Send REQMOD request (mocked).""" - call = self._record_call( - "reqmod", - service=service, - http_request=http_request, - http_body=http_body, - headers=headers, - ) - return await self._execute_call_async(call, "reqmod") - - async def scan_bytes( # type: ignore[override] - self, - data: bytes, - service: str = "avscan", - filename: str | None = None, - ) -> IcapResponse: - """Scan bytes content (mocked).""" - call = self._record_call( - "scan_bytes", - data=data, - service=service, - filename=filename, - ) - return await self._execute_call_async(call, "respmod") - - async def scan_file( # type: ignore[override] - self, - filepath: str | Path, - service: str = "avscan", - ) -> IcapResponse: - """Scan a file (mocked).""" - filepath = Path(filepath) - if not filepath.exists(): - raise FileNotFoundError(f"File not found: {filepath}") - - data = filepath.read_bytes() - call = self._record_call( - "scan_file", - filepath=str(filepath), - service=service, - data=data, - ) - return await self._execute_call_async(call, "respmod") - - async def scan_stream( # type: ignore[override] - self, - stream: BinaryIO, - service: str = "avscan", - filename: str | None = None, - ) -> IcapResponse: - """Scan a stream (mocked).""" - data = stream.read() - call = self._record_call( - "scan_stream", - data=data, - service=service, - filename=filename, - ) - return await self._execute_call_async(call, "respmod") +# Re-export all components for backward compatibility +from .call_record import MockCall, MockResponseExhaustedError +from .matchers import MatcherBuilder, ResponseMatcher +from .mock_async import MockAsyncIcapClient +from .mock_client import MockIcapClient +from .protocols import AsyncResponseCallback, ResponseCallback + +__all__ = [ + "AsyncResponseCallback", + "MatcherBuilder", + "MockAsyncIcapClient", + "MockCall", + "MockIcapClient", + "MockResponseExhaustedError", + "ResponseCallback", + "ResponseMatcher", +] diff --git a/src/icap/pytest_plugin/mock_async.py b/src/icap/pytest_plugin/mock_async.py new file mode 100644 index 0000000..e44ed87 --- /dev/null +++ b/src/icap/pytest_plugin/mock_async.py @@ -0,0 +1,272 @@ +""" +Asynchronous mock ICAP client for testing. + +This module provides MockAsyncIcapClient, an async mock implementation +of AsyncIcapClient that can be used in tests without requiring a real ICAP server. +""" + +from __future__ import annotations + +import inspect +from pathlib import Path +from typing import TYPE_CHECKING, Any, BinaryIO, cast + +from .call_record import MockCall, MockResponseExhaustedError +from .mock_client import MockIcapClient +from .protocols import AsyncResponseCallback, ResponseCallback + +if TYPE_CHECKING: + from icap import IcapResponse + + +class MockAsyncIcapClient(MockIcapClient): + """ + Async mock ICAP client for testing without network I/O. + + Inherits from MockIcapClient and provides the same functionality with + async/await syntax. All ICAP methods (options, respmod, reqmod, scan_*) + are coroutines that must be awaited. + + The configuration API (on_options, on_respmod, on_reqmod, on_any) and + assertion API (assert_called, assert_not_called, assert_scanned, reset_calls) + are synchronous and inherited directly from MockIcapClient. + + Attributes: + host: Mock server hostname (inherited from MockIcapClient). + port: Mock server port (inherited from MockIcapClient). + is_connected: Whether connect() has been called. + calls: List of MockCall objects recording all method invocations. + + Example - Basic async usage: + >>> async def test_scan(mock_async_icap_client): + ... async with mock_async_icap_client as client: + ... response = await client.scan_bytes(b"content") + ... assert response.is_no_modification + ... client.assert_called("scan_bytes", times=1) + + Example - Configure virus detection: + >>> async def test_virus(mock_async_icap_client): + ... mock_async_icap_client.on_respmod( + ... IcapResponseBuilder().virus("Trojan.Gen").build() + ... ) + ... async with mock_async_icap_client as client: + ... response = await client.scan_file("/path/to/file.txt") + ... assert not response.is_no_modification + + Example - Error handling: + >>> async def test_timeout(mock_async_icap_client): + ... mock_async_icap_client.on_any(raises=IcapTimeoutError("Timeout")) + ... async with mock_async_icap_client as client: + ... with pytest.raises(IcapTimeoutError): + ... await client.scan_bytes(b"content") + + See Also: + MockIcapClient: Synchronous version with full API documentation. + IcapResponseBuilder: Fluent builder for creating test responses. + """ + + async def _get_response_with_metadata_async( + self, method: str, call_kwargs: dict[str, Any] + ) -> tuple[IcapResponse | Exception, str]: + """ + Get the next response and metadata for the given method (async version). + + This method determines the response and tracks how it was resolved + (matcher, callback, queue, or default). Supports both sync and async callbacks. + + Args: + method: The ICAP method name ("options", "respmod", "reqmod"). + call_kwargs: The arguments passed to the method call. + + Returns: + A tuple of (response_or_exception, matched_by) where: + - response_or_exception: The IcapResponse or Exception to return/raise + - matched_by: String indicating resolution source: "matcher", "callback", + "queue", or "default" + + Raises: + MockResponseExhaustedError: When queue is exhausted and queue_active is True. + """ + # Check matchers first (highest priority) + for matcher in self._matchers: + if not matcher.is_exhausted() and matcher.matches(**call_kwargs): + return matcher.consume(), "matcher" + + callback = self._callbacks.get(method) + if callback is not None: + self._callback_used[method] = True + # Check if callback is async and await if needed + if inspect.iscoroutinefunction(callback): + async_callback = cast(AsyncResponseCallback, callback) + result = await async_callback(**call_kwargs) + else: + sync_callback = cast(ResponseCallback, callback) + result = sync_callback(**call_kwargs) + return result, "callback" + + queue = self._response_queues[method] + + if queue: + response_or_exception = queue.popleft() + return response_or_exception, "queue" + + if self._queue_active[method]: + raise MockResponseExhaustedError( + f"All queued {method} responses have been consumed. " + f"Configure more responses with on_{method}() or use reset_responses()." + ) + + default_responses: dict[str, IcapResponse | Exception] = { + "options": self._options_response, + "respmod": self._respmod_response, + "reqmod": self._reqmod_response, + } + return default_responses[method], "default" + + async def _execute_call_async(self, call: MockCall, response_method: str) -> IcapResponse: + """ + Execute a recorded call and update it with response metadata (async version). + + This method: + 1. Resolves the response using _get_response_with_metadata_async + 2. Updates the MockCall with response/exception/matched_by + 3. Returns the response or raises the exception + + Args: + call: The MockCall object to update. + response_method: The method key for response lookup ("options", "respmod", "reqmod"). + + Returns: + The IcapResponse. + + Raises: + Exception: If the response was configured as an exception. + """ + try: + response_or_exception, matched_by = await self._get_response_with_metadata_async( + response_method, call.kwargs + ) + call.matched_by = matched_by + + if isinstance(response_or_exception, Exception): + call.exception = response_or_exception + raise response_or_exception + + call.response = response_or_exception + return response_or_exception + + except MockResponseExhaustedError: + # MockResponseExhaustedError is a configuration error, not a mock response + # Still record that it happened + call.matched_by = "queue" + raise + + async def connect(self) -> None: # type: ignore[override] + """Simulate async connection (no-op).""" + self._connected = True + + async def disconnect(self) -> None: # type: ignore[override] + """Simulate async disconnection (no-op).""" + self._connected = False + + async def __aenter__(self) -> MockAsyncIcapClient: + await self.connect() + return self + + async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> bool: + await self.disconnect() + return False + + async def options(self, service: str) -> IcapResponse: # type: ignore[override] + """Send OPTIONS request (mocked).""" + call = self._record_call("options", service=service) + return await self._execute_call_async(call, "options") + + async def respmod( # type: ignore[override] + self, + service: str, + http_request: bytes, + http_response: bytes, + headers: dict[str, str] | None = None, + preview: int | None = None, + ) -> IcapResponse: + """Send RESPMOD request (mocked).""" + call = self._record_call( + "respmod", + service=service, + http_request=http_request, + http_response=http_response, + headers=headers, + preview=preview, + ) + return await self._execute_call_async(call, "respmod") + + async def reqmod( # type: ignore[override] + self, + service: str, + http_request: bytes, + http_body: bytes | None = None, + headers: dict[str, str] | None = None, + ) -> IcapResponse: + """Send REQMOD request (mocked).""" + call = self._record_call( + "reqmod", + service=service, + http_request=http_request, + http_body=http_body, + headers=headers, + ) + return await self._execute_call_async(call, "reqmod") + + async def scan_bytes( # type: ignore[override] + self, + data: bytes, + service: str = "avscan", + filename: str | None = None, + ) -> IcapResponse: + """Scan bytes content (mocked).""" + call = self._record_call( + "scan_bytes", + data=data, + service=service, + filename=filename, + ) + return await self._execute_call_async(call, "respmod") + + async def scan_file( # type: ignore[override] + self, + filepath: str | Path, + service: str = "avscan", + ) -> IcapResponse: + """Scan a file (mocked).""" + filepath = Path(filepath) + if not filepath.exists(): + raise FileNotFoundError(f"File not found: {filepath}") + + data = filepath.read_bytes() + call = self._record_call( + "scan_file", + filepath=str(filepath), + service=service, + data=data, + ) + return await self._execute_call_async(call, "respmod") + + async def scan_stream( # type: ignore[override] + self, + stream: BinaryIO, + service: str = "avscan", + filename: str | None = None, + ) -> IcapResponse: + """Scan a stream (mocked).""" + data = stream.read() + call = self._record_call( + "scan_stream", + data=data, + service=service, + filename=filename, + ) + return await self._execute_call_async(call, "respmod") + + +__all__ = ["MockAsyncIcapClient"] diff --git a/src/icap/pytest_plugin/mock_client.py b/src/icap/pytest_plugin/mock_client.py new file mode 100644 index 0000000..e3bec54 --- /dev/null +++ b/src/icap/pytest_plugin/mock_client.py @@ -0,0 +1,1367 @@ +""" +Synchronous mock ICAP client for testing. + +This module provides MockIcapClient, a mock implementation of IcapClient +that can be used in tests without requiring a real ICAP server. +""" + +from __future__ import annotations + +import time +from collections import deque +from pathlib import Path +from typing import TYPE_CHECKING, Any, BinaryIO + +from .builder import IcapResponseBuilder +from .call_record import MockCall, MockResponseExhaustedError +from .matchers import MatcherBuilder, ResponseMatcher +from .protocols import AsyncResponseCallback, ResponseCallback + +if TYPE_CHECKING: + from icap import IcapResponse + + +class MockIcapClient: + """ + Mock ICAP client for testing without network I/O. + + Implements the full IcapClient interface with configurable responses + and call recording for assertions. By default, all methods return clean/success + responses (204 No Modification for scans, 200 OK for OPTIONS). + + The mock provides six main capabilities: + + 1. **Response Configuration**: Set what responses methods should return. + 2. **Response Sequences**: Queue multiple responses consumed in order. + 3. **Dynamic Callbacks**: Generate responses based on request content. + 4. **Content Matchers**: Declarative rules matching filename, service, or data. + 5. **Call Recording**: Track all calls with rich inspection and filtering. + 6. **Strict Mode**: Validate all configured responses were consumed. + + Response Resolution Order: + When a method is called, the mock determines the response in this order: + 1. **Matchers** - First matching rule wins (via when().respond()) + 2. **Callbacks** - If defined for the method (via on_respmod(callback=...)) + 3. **Queue** - Next queued response if available (via on_respmod(r1, r2, r3)) + 4. **Default** - Single configured response (via on_respmod(response)) + + Attributes: + host: Mock server hostname (default: "mock-icap-server"). + port: Mock server port (default: 1344). + is_connected: Whether connect() has been called. + calls: List of MockCall objects recording all method invocations. + call_count: Total number of calls made. + first_call: First call made (or None if no calls). + last_call: Most recent call (or None if no calls). + last_scan_call: Most recent scan_bytes/scan_file/scan_stream call. + + Configuration Methods: + on_options(*responses, raises, callback): Configure OPTIONS responses. + on_respmod(*responses, raises, callback): Configure scan method responses. + on_reqmod(*responses, raises, callback): Configure REQMOD responses. + on_any(response, raises): Configure all methods at once. + when(service, filename, filename_matches, data_contains): Create matchers. + reset_responses(): Clear all configured responses and matchers. + + Assertion Methods: + assert_called(method, times): Assert method was called N times. + assert_not_called(method): Assert method was never called. + assert_scanned(data): Assert specific bytes were scanned. + assert_called_with(method, **kwargs): Assert last call had specific args. + assert_any_call(method, **kwargs): Assert any call had specific args. + assert_called_in_order(methods): Assert methods called in sequence. + assert_scanned_file(filepath): Assert specific file was scanned. + assert_scanned_with_filename(filename): Assert filename was used. + assert_all_responses_used(): Validate all responses consumed (strict mode). + reset_calls(): Clear call history. + + Query Methods: + get_calls(method): Filter calls by method name. + get_scan_calls(): Get all scan_bytes/scan_file/scan_stream calls. + call_counts_by_method: Dict of method name to call count. + + Example - Basic usage: + >>> client = MockIcapClient() + >>> response = client.scan_bytes(b"safe content") + >>> assert response.is_no_modification # Default is clean + >>> client.assert_called("scan_bytes", times=1) + + Example - Response sequence (consumed in order): + >>> client = MockIcapClient() + >>> client.on_respmod( + ... IcapResponseBuilder().clean().build(), + ... IcapResponseBuilder().virus("Trojan").build(), + ... IcapResponseBuilder().clean().build(), + ... ) + >>> client.scan_bytes(b"file1").is_no_modification # True (clean) + >>> client.scan_bytes(b"file2").is_no_modification # False (virus) + >>> client.scan_bytes(b"file3").is_no_modification # True (clean) + + Example - Dynamic callback: + >>> def eicar_detector(data: bytes, **kwargs) -> IcapResponse: + ... if b"EICAR" in data: + ... return IcapResponseBuilder().virus("EICAR-Test").build() + ... return IcapResponseBuilder().clean().build() + >>> client = MockIcapClient() + >>> client.on_respmod(callback=eicar_detector) + >>> client.scan_bytes(b"safe").is_no_modification # True + >>> client.scan_bytes(b"EICAR test").is_no_modification # False + + Example - Content matchers: + >>> client = MockIcapClient() + >>> client.when(filename_matches=r".*\\.exe$").respond( + ... IcapResponseBuilder().virus("Blocked.Exe").build() + ... ) + >>> client.when(data_contains=b"EICAR").respond( + ... IcapResponseBuilder().virus("EICAR-Test").build() + ... ) + >>> client.scan_bytes(b"safe", filename="doc.pdf").is_no_modification # True + >>> client.scan_bytes(b"safe", filename="app.exe").is_no_modification # False + + Example - Exception injection: + >>> from icap.exception import IcapTimeoutError + >>> client = MockIcapClient() + >>> client.on_any(raises=IcapTimeoutError("Connection timed out")) + >>> client.scan_bytes(b"content") # Raises IcapTimeoutError + + Example - Rich call inspection: + >>> client = MockIcapClient() + >>> client.on_respmod(IcapResponseBuilder().virus("Trojan").build()) + >>> client.scan_bytes(b"malware", filename="bad.exe") + >>> call = client.last_call + >>> call.filename # "bad.exe" + >>> call.was_virus # True + >>> call.matched_by # "default" + >>> call.response.headers["X-Virus-ID"] # "Trojan" + + Example - Strict mode (validate all responses used): + >>> client = MockIcapClient(strict=True) + >>> client.on_respmod( + ... IcapResponseBuilder().clean().build(), + ... IcapResponseBuilder().virus().build(), + ... ) + >>> client.scan_bytes(b"file1") + >>> client.scan_bytes(b"file2") + >>> client.assert_all_responses_used() # Passes - all consumed + + See Also: + MockAsyncIcapClient: Async version with same API but awaitable methods. + IcapResponseBuilder: Fluent builder for creating test responses. + MockCall: Dataclass representing a recorded method call. + ResponseMatcher: Dataclass for content-based matching rules. + MatcherBuilder: Fluent API for creating matchers via when(). + """ + + def __init__( + self, + host: str = "mock-icap-server", + port: int = 1344, + *, + strict: bool = False, + ) -> None: + """ + Initialize the mock ICAP client. + + Args: + host: Mock server hostname (default: "mock-icap-server"). + This value is stored but not used for actual connections. + port: Mock server port (default: 1344). + This value is stored but not used for actual connections. + strict: If True, enables strict mode validation. Use + assert_all_responses_used() to verify all configured + responses were consumed. Default: False. + """ + self._host = host + self._port = port + self._connected = False + self._strict = strict + self._calls: list[MockCall] = [] + + # Response queues for sequential responses + self._response_queues: dict[str, deque[IcapResponse | Exception]] = { + "options": deque(), + "respmod": deque(), + "reqmod": deque(), + } + + # Track whether queue mode is active for each method + # When True and queue is empty, raises MockResponseExhaustedError + self._queue_active: dict[str, bool] = { + "options": False, + "respmod": False, + "reqmod": False, + } + + # Track initial queue sizes for strict mode validation + self._initial_queue_sizes: dict[str, int] = { + "options": 0, + "respmod": 0, + "reqmod": 0, + } + + # Track callback usage for strict mode validation + self._callback_used: dict[str, bool] = { + "options": False, + "respmod": False, + "reqmod": False, + } + + # Default responses (clean/success) - used when queue mode is not active + self._options_response: IcapResponse | Exception = IcapResponseBuilder().options().build() + self._respmod_response: IcapResponse | Exception = IcapResponseBuilder().clean().build() + self._reqmod_response: IcapResponse | Exception = IcapResponseBuilder().clean().build() + + # Callbacks for dynamic response generation + self._callbacks: dict[str, ResponseCallback | AsyncResponseCallback | None] = { + "options": None, + "respmod": None, + "reqmod": None, + } + + # Content matchers for declarative conditional responses + self._matchers: list[ResponseMatcher] = [] + + # === Configuration API === + + def on_options( + self, + *responses: IcapResponse | Exception, + raises: Exception | None = None, + ) -> MockIcapClient: + """ + Configure what the OPTIONS method returns. + + Supports three usage patterns: + 1. **Single response**: Pass one response that all calls will return. + 2. **Response sequence**: Pass multiple responses that are consumed + in order. When exhausted, raises MockResponseExhaustedError. + 3. **Exception injection**: Use raises= to make all calls raise. + + Args: + *responses: One or more IcapResponse objects (or Exceptions). + If multiple provided, they form a queue consumed in order. + raises: Exception to raise on all calls. Takes precedence over responses. + + Returns: + Self for method chaining. + + Raises: + MockResponseExhaustedError: When all queued responses are consumed + and another call is made. + + Example - Single response: + >>> client = MockIcapClient() + >>> client.on_options(IcapResponseBuilder().options(methods=["RESPMOD"]).build()) + >>> response = client.options("avscan") + >>> response.headers["Methods"] + 'RESPMOD' + + Example - Response sequence: + >>> client = MockIcapClient() + >>> client.on_options( + ... IcapResponseBuilder().options(methods=["RESPMOD"]).build(), + ... IcapResponseBuilder().error(503, "Service Unavailable").build(), + ... ) + >>> client.options("avscan").is_success # True (first response) + >>> client.options("avscan").is_success # False (503 error) + + Example - Raise exception: + >>> client.on_options(raises=IcapConnectionError("Server unavailable")) + + See Also: + reset_responses: Clear queued responses without clearing call history. + on_respmod: Configure RESPMOD method responses. + on_reqmod: Configure REQMOD method responses. + """ + if raises is not None: + self._response_queues["options"].clear() + self._queue_active["options"] = False + self._initial_queue_sizes["options"] = 0 + self._options_response = raises + elif len(responses) == 1: + self._response_queues["options"].clear() + self._queue_active["options"] = False + self._initial_queue_sizes["options"] = 0 + self._options_response = responses[0] + elif len(responses) > 1: + self._response_queues["options"].clear() + self._response_queues["options"].extend(responses) + self._queue_active["options"] = True + self._initial_queue_sizes["options"] = len(responses) + return self + + def on_respmod( + self, + *responses: IcapResponse | Exception, + raises: Exception | None = None, + callback: ResponseCallback | None = None, + ) -> MockIcapClient: + """ + Configure what RESPMOD and scan methods return. + + This affects respmod(), scan_bytes(), scan_file(), and scan_stream() + since the scan_* methods use RESPMOD internally. + + Supports four usage patterns: + 1. **Single response**: Pass one response that all calls will return. + 2. **Response sequence**: Pass multiple responses that are consumed + in order. When exhausted, raises MockResponseExhaustedError. + 3. **Exception injection**: Use raises= to make all calls raise. + 4. **Callback**: Use callback= for dynamic response generation. + + Args: + *responses: One or more IcapResponse objects (or Exceptions). + If multiple provided, they form a queue consumed in order. + raises: Exception to raise on all calls. Takes precedence over responses. + callback: Function called with (data, service=, filename=, **kwargs) + that returns an IcapResponse. Used for dynamic responses. + Takes precedence over responses and raises. + + Returns: + Self for method chaining. + + Raises: + MockResponseExhaustedError: When all queued responses are consumed + and another call is made. + + Example - Single response (all scans return same result): + >>> client = MockIcapClient() + >>> client.on_respmod(IcapResponseBuilder().virus("Trojan.Gen").build()) + >>> response = client.scan_bytes(b"content") + >>> assert not response.is_no_modification + + Example - Response sequence (consumed in order): + >>> client = MockIcapClient() + >>> client.on_respmod( + ... IcapResponseBuilder().clean().build(), + ... IcapResponseBuilder().virus("Trojan.Gen").build(), + ... ) + >>> client.scan_bytes(b"file1").is_no_modification # True (clean) + >>> client.scan_bytes(b"file2").is_no_modification # False (virus) + + Example - Exception injection: + >>> client.on_respmod(raises=IcapTimeoutError("Scan timed out")) + + Example - Dynamic callback: + >>> def eicar_detector(data: bytes, **kwargs) -> IcapResponse: + ... if b"EICAR" in data: + ... return IcapResponseBuilder().virus("EICAR-Test").build() + ... return IcapResponseBuilder().clean().build() + >>> client = MockIcapClient() + >>> client.on_respmod(callback=eicar_detector) + >>> client.scan_bytes(b"safe").is_no_modification # True + >>> client.scan_bytes(b"X5O!P%@AP...EICAR...").is_no_modification # False + + See Also: + reset_responses: Clear queued responses without clearing call history. + on_options: Configure OPTIONS method responses. + on_reqmod: Configure REQMOD method responses. + ResponseCallback: Protocol defining the callback signature. + """ + if callback is not None: + # Callback mode: clear other configurations + self._response_queues["respmod"].clear() + self._queue_active["respmod"] = False + self._initial_queue_sizes["respmod"] = 0 + self._callback_used["respmod"] = False + self._callbacks["respmod"] = callback + elif raises is not None: + # Clear queue and set default to exception + self._response_queues["respmod"].clear() + self._queue_active["respmod"] = False + self._initial_queue_sizes["respmod"] = 0 + self._callbacks["respmod"] = None + self._respmod_response = raises + elif len(responses) == 1: + # Single response: set as default, clear queue + self._response_queues["respmod"].clear() + self._queue_active["respmod"] = False + self._initial_queue_sizes["respmod"] = 0 + self._callbacks["respmod"] = None + self._respmod_response = responses[0] + elif len(responses) > 1: + # Multiple responses: queue them all + self._response_queues["respmod"].clear() + self._response_queues["respmod"].extend(responses) + self._queue_active["respmod"] = True + self._initial_queue_sizes["respmod"] = len(responses) + self._callbacks["respmod"] = None + return self + + def on_reqmod( + self, + *responses: IcapResponse | Exception, + raises: Exception | None = None, + ) -> MockIcapClient: + """ + Configure what the REQMOD method returns. + + Supports three usage patterns: + 1. **Single response**: Pass one response that all calls will return. + 2. **Response sequence**: Pass multiple responses that are consumed + in order. When exhausted, raises MockResponseExhaustedError. + 3. **Exception injection**: Use raises= to make all calls raise. + + Args: + *responses: One or more IcapResponse objects (or Exceptions). + If multiple provided, they form a queue consumed in order. + raises: Exception to raise on all calls. Takes precedence over responses. + + Returns: + Self for method chaining. + + Raises: + MockResponseExhaustedError: When all queued responses are consumed + and another call is made. + + Example - Single response: + >>> client = MockIcapClient() + >>> client.on_reqmod(IcapResponseBuilder().clean().build()) + >>> http_req = b"POST /upload HTTP/1.1\\r\\nHost: example.com\\r\\n\\r\\n" + >>> response = client.reqmod("avscan", http_req, b"file content") + >>> assert response.is_no_modification + + Example - Response sequence: + >>> client = MockIcapClient() + >>> client.on_reqmod( + ... IcapResponseBuilder().clean().build(), + ... IcapResponseBuilder().error(500).build(), + ... ) + >>> client.reqmod("avscan", http_req, b"file1").is_success # True + >>> client.reqmod("avscan", http_req, b"file2").is_success # False + + See Also: + reset_responses: Clear queued responses without clearing call history. + on_options: Configure OPTIONS method responses. + on_respmod: Configure RESPMOD method responses. + """ + if raises is not None: + self._response_queues["reqmod"].clear() + self._queue_active["reqmod"] = False + self._initial_queue_sizes["reqmod"] = 0 + self._reqmod_response = raises + elif len(responses) == 1: + self._response_queues["reqmod"].clear() + self._queue_active["reqmod"] = False + self._initial_queue_sizes["reqmod"] = 0 + self._reqmod_response = responses[0] + elif len(responses) > 1: + self._response_queues["reqmod"].clear() + self._response_queues["reqmod"].extend(responses) + self._queue_active["reqmod"] = True + self._initial_queue_sizes["reqmod"] = len(responses) + return self + + def on_any( + self, + response: IcapResponse | None = None, + *, + raises: Exception | None = None, + ) -> MockIcapClient: + """ + Configure all methods (OPTIONS, RESPMOD, REQMOD) at once. + + Convenience method to set the same response or exception for all methods. + Note: This sets a single response for all methods, not a sequence. + + Args: + response: IcapResponse to return from all methods. + raises: Exception to raise from all methods. Takes precedence. + + Returns: + Self for method chaining. + + Example - All methods return clean: + >>> client = MockIcapClient() + >>> client.on_any(IcapResponseBuilder().clean().build()) + + Example - All methods fail: + >>> client.on_any(raises=IcapConnectionError("Server down")) + """ + if raises is not None: + self.on_options(raises=raises) + self.on_respmod(raises=raises) + self.on_reqmod(raises=raises) + elif response is not None: + self.on_options(response) + self.on_respmod(response) + self.on_reqmod(response) + return self + + def reset_responses(self) -> None: + """ + Clear all response queues and reset to defaults. + + This clears any queued responses but does NOT clear call history. + Use reset_calls() to clear call history. + + After calling reset_responses(), the mock returns default responses: + - OPTIONS: 200 OK with standard server capabilities + - RESPMOD/scan_*: 204 No Modification (clean) + - REQMOD: 204 No Modification (clean) + + Example: + >>> client = MockIcapClient() + >>> client.on_respmod( + ... IcapResponseBuilder().virus().build(), + ... IcapResponseBuilder().virus().build(), + ... ) + >>> client.scan_bytes(b"file1") # Returns virus + >>> client.reset_responses() + >>> client.scan_bytes(b"file2") # Returns clean (default) + >>> len(client.calls) # 2 - call history preserved + 2 + + See Also: + reset_calls: Clear call history without resetting responses. + """ + for queue in self._response_queues.values(): + queue.clear() + + for method in self._queue_active: + self._queue_active[method] = False + + self._options_response = IcapResponseBuilder().options().build() + self._respmod_response = IcapResponseBuilder().clean().build() + self._reqmod_response = IcapResponseBuilder().clean().build() + + for method in self._callbacks: + self._callbacks[method] = None + + self._matchers.clear() + + def when( + self, + *, + service: str | None = None, + filename: str | None = None, + filename_matches: str | None = None, + data_contains: bytes | None = None, + ) -> MatcherBuilder: + """ + Create a conditional response matcher. + + Returns a MatcherBuilder that collects match criteria and registers + the matcher when respond() is called. Matchers are checked in registration + order; the first match wins. Matchers take highest priority in response + resolution (before callbacks, queues, and defaults). + + Args: + service: Exact service name to match (e.g., "avscan"). + filename: Exact filename to match (e.g., "malware.exe"). + filename_matches: Regex pattern string to match against filename. + data_contains: Bytes that must be present in scanned content. + + Returns: + MatcherBuilder for configuring the response. + + Example - Filename matching: + >>> client = MockIcapClient() + >>> client.when(filename="malware.exe").respond( + ... IcapResponseBuilder().virus("Known.Malware").build() + ... ) + >>> client.scan_bytes(b"content", filename="malware.exe").is_no_modification + False # virus detected + >>> client.scan_bytes(b"content", filename="safe.txt").is_no_modification + True # falls through to default + + Example - Pattern matching: + >>> client.when(filename_matches=r".*\\.exe$").respond( + ... IcapResponseBuilder().virus("Policy.BlockedExecutable").build() + ... ) + + Example - Content matching: + >>> client.when(data_contains=b"EICAR").respond( + ... IcapResponseBuilder().virus("EICAR-Test").build() + ... ) + + Example - Combined criteria: + >>> client.when(service="avscan", data_contains=b"malicious").respond( + ... IcapResponseBuilder().virus().build() + ... ) + + Example - Multiple matchers with chaining: + >>> client.when(filename="virus.exe").respond( + ... IcapResponseBuilder().virus().build() + ... ).when(filename="clean.txt").respond( + ... IcapResponseBuilder().clean().build() + ... ) + + Example - Limited use matcher: + >>> client.when(data_contains=b"bad").respond( + ... IcapResponseBuilder().virus().build(), + ... times=2, # Only match first 2 times + ... ) + + See Also: + MatcherBuilder: Builder returned by this method. + ResponseMatcher: The underlying matcher dataclass. + reset_responses: Clears all matchers along with other configurations. + """ + return MatcherBuilder( + self, + service=service, + filename=filename, + filename_matches=filename_matches, + data_contains=data_contains, + ) + + # === Assertion API === + + @property + def calls(self) -> list[MockCall]: + """ + Get a copy of all recorded method calls. + + Returns a copy to prevent accidental modification. Each MockCall + contains the method name, timestamp, and keyword arguments. + + Returns: + List of MockCall objects in chronological order. + + Example: + >>> client = MockIcapClient() + >>> client.scan_bytes(b"test1") + >>> client.scan_bytes(b"test2") + >>> len(client.calls) + 2 + >>> client.calls[0].kwargs["data"] + b'test1' + """ + return self._calls.copy() + + @property + def first_call(self) -> MockCall | None: + """ + Get the first recorded call, or None if no calls were made. + + Returns: + The first MockCall, or None if calls list is empty. + + Example: + >>> client = MockIcapClient() + >>> client.first_call # None + >>> client.scan_bytes(b"first") + >>> client.scan_bytes(b"second") + >>> client.first_call.data + b'first' + """ + return self._calls[0] if self._calls else None + + @property + def last_call(self) -> MockCall | None: + """ + Get the most recent call, or None if no calls were made. + + Returns: + The last MockCall, or None if calls list is empty. + + Example: + >>> client = MockIcapClient() + >>> client.last_call # None + >>> client.scan_bytes(b"first") + >>> client.scan_bytes(b"second") + >>> client.last_call.data + b'second' + """ + return self._calls[-1] if self._calls else None + + @property + def last_scan_call(self) -> MockCall | None: + """ + Get the most recent scan call (scan_bytes, scan_file, scan_stream). + + Returns: + The last scan-related MockCall, or None if no scan calls were made. + + Example: + >>> client = MockIcapClient() + >>> client.options("avscan") # Not a scan + >>> client.scan_bytes(b"test") # This is a scan + >>> client.options("avscan") # Not a scan + >>> client.last_scan_call.method + 'scan_bytes' + >>> client.last_scan_call.data + b'test' + """ + scan_methods = {"scan_bytes", "scan_file", "scan_stream"} + for call in reversed(self._calls): + if call.method in scan_methods: + return call + return None + + def get_calls(self, method: str | None = None) -> list[MockCall]: + """ + Get calls, optionally filtered by method name. + + Args: + method: If provided, only return calls with this method name. + If None, returns all calls. + + Returns: + List of matching MockCall objects in chronological order. + + Example: + >>> client = MockIcapClient() + >>> client.options("avscan") + >>> client.scan_bytes(b"file1") + >>> client.scan_bytes(b"file2") + >>> len(client.get_calls()) # All calls + 3 + >>> len(client.get_calls("scan_bytes")) # Only scan_bytes + 2 + >>> [c.data for c in client.get_calls("scan_bytes")] + [b'file1', b'file2'] + """ + if method is None: + return self._calls.copy() + return [c for c in self._calls if c.method == method] + + def get_scan_calls(self) -> list[MockCall]: + """ + Get all scan-related calls (scan_bytes, scan_file, scan_stream). + + Convenience method for filtering to only scan operations, excluding + lower-level calls like options, respmod, and reqmod. + + Returns: + List of scan MockCall objects in chronological order. + + Example: + >>> client = MockIcapClient() + >>> client.options("avscan") + >>> client.scan_bytes(b"file1") + >>> client.scan_file("/path/to/file.txt") + >>> len(client.get_scan_calls()) + 2 + >>> [c.method for c in client.get_scan_calls()] + ['scan_bytes', 'scan_file'] + """ + scan_methods = {"scan_bytes", "scan_file", "scan_stream"} + return [c for c in self._calls if c.method in scan_methods] + + @property + def call_count(self) -> int: + """ + Get the total number of calls made. + + Returns: + The number of recorded method calls. + + Example: + >>> client = MockIcapClient() + >>> client.call_count + 0 + >>> client.scan_bytes(b"test") + >>> client.options("avscan") + >>> client.call_count + 2 + """ + return len(self._calls) + + @property + def call_counts_by_method(self) -> dict[str, int]: + """ + Get call counts grouped by method name. + + Returns: + Dictionary mapping method names to their call counts. + + Example: + >>> client = MockIcapClient() + >>> client.scan_bytes(b"file1") + >>> client.scan_bytes(b"file2") + >>> client.options("avscan") + >>> client.call_counts_by_method + {'scan_bytes': 2, 'options': 1} + """ + counts: dict[str, int] = {} + for call in self._calls: + counts[call.method] = counts.get(call.method, 0) + 1 + return counts + + def assert_called(self, method: str, *, times: int | None = None) -> None: + """ + Assert that a method was called. + + Args: + method: Method name to check (e.g., "scan_bytes", "options", "respmod"). + times: If provided, assert the method was called exactly this many times. + + Raises: + AssertionError: If the method was never called, or was called a + different number of times than expected. + + Example: + >>> client = MockIcapClient() + >>> client.scan_bytes(b"content") + >>> client.assert_called("scan_bytes") # Passes + >>> client.assert_called("scan_bytes", times=1) # Passes + >>> client.assert_called("scan_bytes", times=2) # Raises AssertionError + >>> client.assert_called("options") # Raises AssertionError + """ + matching = [c for c in self._calls if c.method == method] + if not matching: + raise AssertionError(f"Method '{method}' was never called") + if times is not None and len(matching) != times: + raise AssertionError( + f"Method '{method}' was called {len(matching)} times, expected {times}" + ) + + def assert_not_called(self, method: str | None = None) -> None: + """ + Assert that a method (or any method) was not called. + + Args: + method: Specific method name to check. If None, asserts no methods + were called at all. + + Raises: + AssertionError: If the method (or any method) was called. + + Example: + >>> client = MockIcapClient() + >>> client.assert_not_called() # Passes - nothing called yet + >>> client.assert_not_called("options") # Passes + >>> client.scan_bytes(b"test") + >>> client.assert_not_called("options") # Still passes + >>> client.assert_not_called("scan_bytes") # Raises AssertionError + >>> client.assert_not_called() # Raises AssertionError + """ + if method is None: + if self._calls: + raise AssertionError(f"Expected no calls, got: {self._calls}") + else: + matching = [c for c in self._calls if c.method == method] + if matching: + raise AssertionError(f"Method '{method}' was called {len(matching)} times") + + def assert_scanned(self, data: bytes) -> None: + """ + Assert that specific content was scanned. + + Checks if the given bytes were passed to scan_bytes() or respmod(). + For respmod(), checks if the http_response ends with the given data. + + Args: + data: The exact bytes that should have been scanned. + + Raises: + AssertionError: If the data was not found in any scan call. + + Example: + >>> client = MockIcapClient() + >>> client.scan_bytes(b"test content") + >>> client.assert_scanned(b"test content") # Passes + >>> client.assert_scanned(b"other") # Raises AssertionError + """ + for call in self._calls: + if call.method in ("scan_bytes", "respmod"): + if call.kwargs.get("data") == data: + return + if call.kwargs.get("http_response", b"").endswith(data): + return + raise AssertionError(f"Content {data!r} was not scanned") + + def reset_calls(self) -> None: + """ + Clear the call history. + + Use this between test phases to reset assertions. + + Example: + >>> client = MockIcapClient() + >>> client.scan_bytes(b"test1") + >>> client.assert_called("scan_bytes", times=1) + >>> client.reset_calls() + >>> client.assert_not_called() # Passes - history cleared + >>> client.scan_bytes(b"test2") + >>> client.assert_called("scan_bytes", times=1) # Only counts new call + """ + self._calls.clear() + + def assert_called_with(self, method: str, **kwargs: Any) -> None: + """ + Assert that a method was called with specific arguments. + + Finds the most recent call to the method and checks that the provided + kwargs match the call's arguments. Only the provided kwargs are checked; + additional arguments in the call are allowed. + + Args: + method: Method name to check. + **kwargs: Expected keyword arguments (partial match). + + Raises: + AssertionError: If the method was never called, or the most recent + call doesn't match the expected kwargs. + + Example: + >>> client = MockIcapClient() + >>> client.scan_bytes(b"content", filename="test.txt", service="avscan") + >>> client.assert_called_with("scan_bytes", data=b"content") # Passes + >>> client.assert_called_with("scan_bytes", filename="test.txt") # Passes + >>> client.assert_called_with("scan_bytes", filename="other.txt") # Fails + """ + matching = [c for c in self._calls if c.method == method] + if not matching: + raise AssertionError(f"Method '{method}' was never called") + + last_call = matching[-1] + for key, expected_value in kwargs.items(): + actual_value = last_call.kwargs.get(key) + if actual_value != expected_value: + raise AssertionError( + f"Method '{method}' called with {key}={actual_value!r}, " + f"expected {key}={expected_value!r}" + ) + + def assert_any_call(self, method: str, **kwargs: Any) -> None: + """ + Assert that at least one call matches the specified arguments. + + Searches all calls to the method for one that matches all provided kwargs. + Unlike assert_called_with, this doesn't require the match to be the most + recent call. + + Args: + method: Method name to check. + **kwargs: Expected keyword arguments (partial match). + + Raises: + AssertionError: If no call matches the expected kwargs. + + Example: + >>> client = MockIcapClient() + >>> client.scan_bytes(b"first", filename="a.txt") + >>> client.scan_bytes(b"second", filename="b.txt") + >>> client.scan_bytes(b"third", filename="c.txt") + >>> client.assert_any_call("scan_bytes", filename="b.txt") # Passes + >>> client.assert_any_call("scan_bytes", filename="z.txt") # Fails + """ + matching = [c for c in self._calls if c.method == method] + if not matching: + raise AssertionError(f"Method '{method}' was never called") + + for call in matching: + if all(call.kwargs.get(k) == v for k, v in kwargs.items()): + return # Found a match + + raise AssertionError( + f"No call to '{method}' matched kwargs {kwargs!r}. " + f"Actual calls: {[c.kwargs for c in matching]}" + ) + + def assert_called_in_order(self, methods: list[str]) -> None: + """ + Assert that methods were called in the specified order. + + Checks that the call history contains the methods in the given order. + Other calls may appear between the specified methods. + + Args: + methods: List of method names in expected order. + + Raises: + AssertionError: If the methods weren't called in order. + + Example: + >>> client = MockIcapClient() + >>> client.options("avscan") + >>> client.scan_bytes(b"test") + >>> client.assert_called_in_order(["options", "scan_bytes"]) # Passes + >>> client.assert_called_in_order(["scan_bytes", "options"]) # Fails + """ + if not methods: + return + + actual_methods = [c.method for c in self._calls] + method_index = 0 + + for actual in actual_methods: + if method_index < len(methods) and actual == methods[method_index]: + method_index += 1 + + if method_index != len(methods): + missing = methods[method_index:] + raise AssertionError( + f"Methods not called in expected order. " + f"Expected: {methods}, Actual order: {actual_methods}. " + f"Missing or out of order: {missing}" + ) + + def assert_scanned_file(self, filepath: str) -> None: + """ + Assert that a specific file path was scanned. + + Checks if scan_file() was called with the given filepath. + + Args: + filepath: The file path that should have been scanned. + + Raises: + AssertionError: If the file was not scanned. + + Example: + >>> client = MockIcapClient() + >>> client.scan_file("/path/to/file.txt") + >>> client.assert_scanned_file("/path/to/file.txt") # Passes + >>> client.assert_scanned_file("/other/file.txt") # Fails + """ + for call in self._calls: + if call.method == "scan_file" and call.kwargs.get("filepath") == filepath: + return + raise AssertionError(f"File '{filepath}' was not scanned") + + def assert_scanned_with_filename(self, filename: str) -> None: + """ + Assert that a scan was made with a specific filename argument. + + Checks scan_bytes and scan_stream calls for the given filename. + Note: This checks the filename argument, not the filepath in scan_file(). + + Args: + filename: The filename argument that should have been used. + + Raises: + AssertionError: If no scan was made with that filename. + + Example: + >>> client = MockIcapClient() + >>> client.scan_bytes(b"content", filename="report.pdf") + >>> client.assert_scanned_with_filename("report.pdf") # Passes + >>> client.assert_scanned_with_filename("other.pdf") # Fails + """ + for call in self._calls: + if call.method in ("scan_bytes", "scan_stream"): + if call.kwargs.get("filename") == filename: + return + raise AssertionError(f"No scan was made with filename '{filename}'") + + def assert_all_responses_used(self) -> None: + """ + Assert that all configured responses were consumed (strict mode validation). + + This method verifies that: + 1. All queued responses were consumed (queue is empty) + 2. All configured callbacks were invoked at least once + 3. All registered matchers were triggered at least once + + This is useful for ensuring that test setup matches test behavior - + if you configure specific responses, they should all be used. + + Raises: + AssertionError: If any configured responses, callbacks, or matchers + were not used during the test. + + Example - All queued responses consumed: + >>> client = MockIcapClient(strict=True) + >>> client.on_respmod( + ... IcapResponseBuilder().clean().build(), + ... IcapResponseBuilder().virus().build(), + ... ) + >>> client.scan_bytes(b"file1") + >>> client.scan_bytes(b"file2") + >>> client.assert_all_responses_used() # Passes + + Example - Unused responses fail: + >>> client = MockIcapClient(strict=True) + >>> client.on_respmod( + ... IcapResponseBuilder().clean().build(), + ... IcapResponseBuilder().virus().build(), + ... ) + >>> client.scan_bytes(b"file1") # Only consume first response + >>> client.assert_all_responses_used() # Raises AssertionError + + Example - Unused callback fails: + >>> client = MockIcapClient(strict=True) + >>> client.on_respmod(callback=lambda **kwargs: IcapResponseBuilder().clean().build()) + >>> client.assert_all_responses_used() # Raises AssertionError (callback never called) + + Example - Unused matcher fails: + >>> client = MockIcapClient(strict=True) + >>> client.when(filename="malware.exe").respond( + ... IcapResponseBuilder().virus().build() + ... ) + >>> client.scan_bytes(b"content", filename="safe.txt") # Matcher not triggered + >>> client.assert_all_responses_used() # Raises AssertionError + + See Also: + strict: Constructor parameter to enable strict mode. + """ + errors: list[str] = [] + + for method, queue in self._response_queues.items(): + initial_size = self._initial_queue_sizes[method] + remaining = len(queue) + if remaining > 0: + consumed = initial_size - remaining + errors.append( + f"{method}: {remaining} of {initial_size} queued responses not consumed " + f"(consumed {consumed})" + ) + + for method, callback in self._callbacks.items(): + if callback is not None and not self._callback_used[method]: + errors.append(f"{method}: callback was configured but never invoked") + + for i, matcher in enumerate(self._matchers): + if matcher._match_count == 0: + criteria = [] + if matcher.service: + criteria.append(f"service={matcher.service!r}") + if matcher.filename: + criteria.append(f"filename={matcher.filename!r}") + if matcher.filename_pattern: + criteria.append(f"filename_pattern={matcher.filename_pattern.pattern!r}") + if matcher.data_contains: + criteria.append(f"data_contains={matcher.data_contains!r}") + criteria_str = ", ".join(criteria) if criteria else "no criteria" + errors.append(f"matcher[{i}] ({criteria_str}): never matched") + + if errors: + raise AssertionError( + "Not all configured responses were used:\n - " + "\n - ".join(errors) + ) + + # === IcapClient Interface === + + @property + def host(self) -> str: + return self._host + + @property + def port(self) -> int: + return self._port + + @port.setter + def port(self, value: int) -> None: + if not isinstance(value, int): + raise TypeError("Port is not a valid type. Please enter an int value.") + self._port = value + + @property + def is_connected(self) -> bool: + return self._connected + + def connect(self) -> None: + """Simulate connection (no-op).""" + self._connected = True + + def disconnect(self) -> None: + """Simulate disconnection (no-op).""" + self._connected = False + + def __enter__(self) -> MockIcapClient: + self.connect() + return self + + def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> bool: + self.disconnect() + return False + + def _record_call(self, method: str, **kwargs: Any) -> MockCall: + """ + Record a method call and return the MockCall object. + + Args: + method: Name of the method being called. + **kwargs: Arguments passed to the method. + + Returns: + The newly created MockCall object (also appended to self._calls). + """ + call = MockCall( + method=method, + timestamp=time.time(), + kwargs=kwargs, + call_index=len(self._calls), + ) + self._calls.append(call) + return call + + def _get_response_with_metadata( + self, method: str, call_kwargs: dict[str, Any] + ) -> tuple[IcapResponse | Exception, str]: + """ + Get the next response and metadata for the given method. + + This method determines the response and tracks how it was resolved + (matcher, callback, queue, or default). + + Args: + method: The ICAP method name ("options", "respmod", "reqmod"). + call_kwargs: The arguments passed to the method call. + + Returns: + A tuple of (response_or_exception, matched_by) where: + - response_or_exception: The IcapResponse or Exception to return/raise + - matched_by: String indicating resolution source: "matcher", "callback", + "queue", or "default" + + Raises: + MockResponseExhaustedError: When queue is exhausted and queue_active is True. + """ + # Check matchers first (highest priority) + for matcher in self._matchers: + if not matcher.is_exhausted() and matcher.matches(**call_kwargs): + return matcher.consume(), "matcher" + + callback = self._callbacks.get(method) + if callback is not None: + self._callback_used[method] = True + # Type checker can't narrow callback type; sync client only uses sync callbacks + return callback(**call_kwargs), "callback" # type: ignore[return-value] + + queue = self._response_queues[method] + + if queue: + response_or_exception = queue.popleft() + return response_or_exception, "queue" + + if self._queue_active[method]: + raise MockResponseExhaustedError( + f"All queued {method} responses have been consumed. " + f"Configure more responses with on_{method}() or use reset_responses()." + ) + + default_responses: dict[str, IcapResponse | Exception] = { + "options": self._options_response, + "respmod": self._respmod_response, + "reqmod": self._reqmod_response, + } + return default_responses[method], "default" + + def _execute_call(self, call: MockCall, response_method: str) -> IcapResponse: + """ + Execute a recorded call and update it with response metadata. + + This method: + 1. Resolves the response using _get_response_with_metadata + 2. Updates the MockCall with response/exception/matched_by + 3. Returns the response or raises the exception + + Args: + call: The MockCall object to update. + response_method: The method key for response lookup ("options", "respmod", "reqmod"). + + Returns: + The IcapResponse. + + Raises: + Exception: If the response was configured as an exception. + """ + try: + response_or_exception, matched_by = self._get_response_with_metadata( + response_method, call.kwargs + ) + call.matched_by = matched_by + + if isinstance(response_or_exception, Exception): + call.exception = response_or_exception + raise response_or_exception + + call.response = response_or_exception + return response_or_exception + + except MockResponseExhaustedError: + # MockResponseExhaustedError is a configuration error, not a mock response + # Still record that it happened + call.matched_by = "queue" + raise + + def options(self, service: str) -> IcapResponse: + """Send OPTIONS request (mocked).""" + call = self._record_call("options", service=service) + return self._execute_call(call, "options") + + def respmod( + self, + service: str, + http_request: bytes, + http_response: bytes, + headers: dict[str, str] | None = None, + preview: int | None = None, + ) -> IcapResponse: + """Send RESPMOD request (mocked).""" + call = self._record_call( + "respmod", + service=service, + http_request=http_request, + http_response=http_response, + headers=headers, + preview=preview, + ) + return self._execute_call(call, "respmod") + + def reqmod( + self, + service: str, + http_request: bytes, + http_body: bytes | None = None, + headers: dict[str, str] | None = None, + ) -> IcapResponse: + """Send REQMOD request (mocked).""" + call = self._record_call( + "reqmod", + service=service, + http_request=http_request, + http_body=http_body, + headers=headers, + ) + return self._execute_call(call, "reqmod") + + def scan_bytes( + self, + data: bytes, + service: str = "avscan", + filename: str | None = None, + ) -> IcapResponse: + """Scan bytes content (mocked).""" + call = self._record_call( + "scan_bytes", + data=data, + service=service, + filename=filename, + ) + return self._execute_call(call, "respmod") + + def scan_file( + self, + filepath: str | Path, + service: str = "avscan", + ) -> IcapResponse: + """Scan a file (mocked - actually reads the file).""" + filepath = Path(filepath) + if not filepath.exists(): + raise FileNotFoundError(f"File not found: {filepath}") + + data = filepath.read_bytes() + call = self._record_call( + "scan_file", + filepath=str(filepath), + service=service, + data=data, + ) + return self._execute_call(call, "respmod") + + def scan_stream( + self, + stream: BinaryIO, + service: str = "avscan", + filename: str | None = None, + chunk_size: int = 0, + ) -> IcapResponse: + """Scan a stream (mocked - actually reads the stream).""" + data = stream.read() + call = self._record_call( + "scan_stream", + data=data, + service=service, + filename=filename, + chunk_size=chunk_size, + ) + return self._execute_call(call, "respmod") + + +__all__ = ["MockIcapClient"] diff --git a/src/icap/pytest_plugin/protocols.py b/src/icap/pytest_plugin/protocols.py new file mode 100644 index 0000000..845fa53 --- /dev/null +++ b/src/icap/pytest_plugin/protocols.py @@ -0,0 +1,97 @@ +""" +Protocol definitions for mock response callbacks. + +This module defines the callback protocols used for dynamic response generation +in MockIcapClient and MockAsyncIcapClient. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Protocol + +if TYPE_CHECKING: + from icap import IcapResponse + + +class ResponseCallback(Protocol): + """ + Protocol for synchronous response callbacks. + + Callbacks receive the request context and return an IcapResponse. + Use this for dynamic response generation based on content, filename, + or service name. + + The callback signature is flexible: + - Required: `data` (bytes) - the content being scanned + - Optional keyword arguments: `service`, `filename`, and others + + Example signatures (all valid): + >>> def simple_callback(data: bytes, **kwargs) -> IcapResponse: ... + >>> def detailed_callback( + ... data: bytes, + ... *, + ... service: str, + ... filename: str | None, + ... **kwargs + ... ) -> IcapResponse: ... + + Example: + >>> def eicar_detector(data: bytes, **kwargs) -> IcapResponse: + ... if b"EICAR" in data: + ... return IcapResponseBuilder().virus("EICAR-Test").build() + ... return IcapResponseBuilder().clean().build() + >>> + >>> client = MockIcapClient() + >>> client.on_respmod(callback=eicar_detector) + + See Also: + AsyncResponseCallback: Async version for MockAsyncIcapClient. + MockIcapClient.on_respmod: Configure callbacks for scan methods. + """ + + def __call__( + self, + data: bytes, + *, + service: str, + filename: str | None = None, + **kwargs: Any, + ) -> IcapResponse: ... + + +class AsyncResponseCallback(Protocol): + """ + Protocol for asynchronous response callbacks. + + Async version of ResponseCallback for use with MockAsyncIcapClient. + Callbacks receive the request context and return an IcapResponse. + + Note: MockAsyncIcapClient also accepts synchronous callbacks for + convenience - they will be called directly without awaiting. + + Example: + >>> async def async_scanner(data: bytes, **kwargs) -> IcapResponse: + ... # Can perform async operations if needed + ... if b"EICAR" in data: + ... return IcapResponseBuilder().virus("EICAR-Test").build() + ... return IcapResponseBuilder().clean().build() + >>> + >>> client = MockAsyncIcapClient() + >>> client.on_respmod(callback=async_scanner) + + See Also: + ResponseCallback: Sync version for MockIcapClient. + MockAsyncIcapClient.on_respmod: Configure callbacks for scan methods. + """ + + async def __call__( + self, + data: bytes, + *, + service: str, + filename: str | None = None, + **kwargs: Any, + ) -> IcapResponse: ... + + +__all__ = ["ResponseCallback", "AsyncResponseCallback"] diff --git a/src/icap/response.py b/src/icap/response.py index 9c80e20..6afc811 100644 --- a/src/icap/response.py +++ b/src/icap/response.py @@ -1,4 +1,120 @@ -from typing import Dict +from dataclasses import dataclass +from typing import Dict, Iterator, MutableMapping, Optional + +__all__ = ["CaseInsensitiveDict", "EncapsulatedParts", "IcapResponse"] + + +@dataclass +class EncapsulatedParts: + """ + Parsed representation of the ICAP Encapsulated header. + + The Encapsulated header indicates byte offsets of different parts of the + encapsulated HTTP message within the ICAP response body. This is useful + for understanding which parts of the HTTP message were modified. + + Attributes: + req_hdr: Offset of the encapsulated HTTP request headers, or None if not present. + req_body: Offset of the encapsulated HTTP request body, or None if not present. + res_hdr: Offset of the encapsulated HTTP response headers, or None if not present. + res_body: Offset of the encapsulated HTTP response body, or None if not present. + null_body: Offset indicating no body follows, or None if not present. + opt_body: Offset of OPTIONS response body, or None if not present. + + Example: + >>> response.encapsulated.res_hdr + 0 + >>> response.encapsulated.res_body + 128 + """ + + req_hdr: Optional[int] = None + req_body: Optional[int] = None + res_hdr: Optional[int] = None + res_body: Optional[int] = None + null_body: Optional[int] = None + opt_body: Optional[int] = None + + @classmethod + def parse(cls, header_value: str) -> "EncapsulatedParts": + """ + Parse an Encapsulated header value. + + Args: + header_value: The Encapsulated header value (e.g., "res-hdr=0, res-body=128") + + Returns: + EncapsulatedParts with parsed offsets. + + Example: + >>> EncapsulatedParts.parse("req-hdr=0, res-hdr=45, res-body=128") + EncapsulatedParts(req_hdr=0, req_body=None, res_hdr=45, res_body=128, ...) + """ + parts = cls() + for segment in header_value.split(","): + segment = segment.strip() + if "=" in segment: + name, value = segment.split("=", 1) + name = name.strip().replace("-", "_") + try: + offset = int(value.strip()) + # Only set valid non-negative offsets on known fields + if offset >= 0 and hasattr(parts, name): + setattr(parts, name, offset) + except ValueError: + pass # Skip invalid offset values + return parts + + +class CaseInsensitiveDict(MutableMapping[str, str]): + """ + A dictionary with case-insensitive string keys. + + Per RFC 3507, ICAP header field names are case-insensitive, following HTTP/1.1 + conventions (RFC 7230 Section 3.2). This dictionary allows header lookups + regardless of case while preserving the original case for display. + + Example: + >>> headers = CaseInsensitiveDict() + >>> headers["X-Virus-ID"] = "EICAR" + >>> headers["x-virus-id"] + 'EICAR' + >>> headers["X-VIRUS-ID"] + 'EICAR' + """ + + def __init__(self, data: Optional[Dict[str, str]] = None) -> None: + # Store as {lowercase_key: (original_key, value)} + self._store: Dict[str, tuple[str, str]] = {} + if data: + for key, value in data.items(): + self[key] = value + + def __setitem__(self, key: str, value: str) -> None: + # Store with lowercase key, but preserve original case + self._store[key.lower()] = (key, value) + + def __getitem__(self, key: str) -> str: + return self._store[key.lower()][1] + + def __delitem__(self, key: str) -> None: + del self._store[key.lower()] + + def __iter__(self) -> Iterator[str]: + # Iterate over original-case keys + return (original_key for original_key, _ in self._store.values()) + + def __len__(self) -> int: + return len(self._store) + + def __contains__(self, key: object) -> bool: + if not isinstance(key, str): + return False + return key.lower() in self._store + + def __repr__(self) -> str: + items = ", ".join(f"{k!r}: {v!r}" for k, v in self.items()) + return f"CaseInsensitiveDict({{{items}}})" class IcapResponse: @@ -18,7 +134,9 @@ class IcapResponse: - 404: Service Not Found - 500+: Server Error status_message: Human-readable status message (e.g., "OK", "No Content"). - headers: Dictionary of ICAP response headers. May include: + headers: Case-insensitive dictionary of ICAP response headers (per RFC 3507). + Lookups work regardless of case: headers["X-Virus-ID"] == headers["x-virus-id"]. + May include: - "X-Virus-ID": Name of detected virus (when virus found) - "X-Infection-Found": Details about the infection - "ISTag": Server state tag for caching @@ -35,19 +153,30 @@ class IcapResponse: ... print(f"Threat detected: {virus}") """ - def __init__(self, status_code: int, status_message: str, headers: Dict[str, str], body: bytes): + def __init__( + self, + status_code: int, + status_message: str, + headers: MutableMapping[str, str], + body: bytes, + ): """ Initialize ICAP response. Args: status_code: ICAP status code (e.g., 200, 204). status_message: Status message (e.g., "OK", "No Content"). - headers: ICAP response headers as a dictionary. + headers: ICAP response headers. Will be converted to case-insensitive + dictionary if not already (per RFC 3507, header names are case-insensitive). body: Response body bytes (may contain modified HTTP response). """ self.status_code = status_code self.status_message = status_message - self.headers = headers + # Ensure headers are case-insensitive per RFC 3507 + if isinstance(headers, CaseInsensitiveDict): + self.headers = headers + else: + self.headers = CaseInsensitiveDict(dict(headers)) self.body = body @property @@ -92,6 +221,62 @@ def is_no_modification(self) -> bool: """ return self.status_code == 204 + @property + def encapsulated(self) -> Optional[EncapsulatedParts]: + """ + Parse and return the Encapsulated header parts. + + The Encapsulated header indicates byte offsets of HTTP message parts + within the ICAP response body. This helps identify which parts were + modified by the ICAP server. + + Returns: + EncapsulatedParts with parsed offsets, or None if no Encapsulated header. + + Example: + >>> response = client.respmod("avscan", http_request, http_response) + >>> if response.encapsulated and response.encapsulated.res_body is not None: + ... body_offset = response.encapsulated.res_body + ... modified_body = response.body[body_offset:] + """ + enc_header = self.headers.get("Encapsulated") + if enc_header is None: + return None + return EncapsulatedParts.parse(enc_header) + + @property + def istag(self) -> Optional[str]: + """ + Get the ISTag (ICAP Service Tag) header value. + + The ISTag is defined in RFC 3507 Section 4.7 and represents the current + state/version of the ICAP service. It typically changes when: + + - Virus definitions are updated (for AV scanners) + - Scanning engine configuration changes + - Service policies are modified + + This is useful for cache validation: if the ISTag hasn't changed since + a previous scan, cached results for unchanged files remain valid. + + Returns: + The ISTag string (including quotes if present), or None if not provided. + + Example: + >>> # Get ISTag from OPTIONS response + >>> response = client.options("avscan") + >>> current_istag = response.istag + >>> print(f"Service version: {current_istag}") + Service version: "AV-2026030101-signatures" + + >>> # Use for cache validation (application-level logic) + >>> if current_istag == cached_istag: + ... print("Cached scan results still valid") + ... else: + ... print("Service updated, rescan needed") + """ + return self.headers.get("ISTag") + @classmethod def parse(cls, data: bytes) -> "IcapResponse": """ @@ -116,13 +301,23 @@ def parse(cls, data: bytes) -> "IcapResponse": raise ValueError(f"Invalid ICAP status line: {status_line}") status_code = int(status_parts[1]) + + # Validate status code is in valid HTTP/ICAP range (100-599) + if not (100 <= status_code <= 599): + raise ValueError(f"Invalid ICAP status code: {status_code} (must be 100-599)") status_message = status_parts[2] - headers = {} + headers: CaseInsensitiveDict = CaseInsensitiveDict() for line in lines[1:]: if ":" in line: key, value = line.split(":", 1) - headers[key.strip()] = value.strip() + key = key.strip() + value = value.strip() + # Handle duplicate headers by combining with comma (RFC 7230 Section 3.2.2) + if key in headers: + headers[key] = headers[key] + ", " + value + else: + headers[key] = value return cls(status_code, status_message, headers, body) diff --git a/tests/conftest.py b/tests/conftest.py index 38651bb..ad91b94 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,13 +3,32 @@ from __future__ import annotations import shutil +import socket import ssl import subprocess import time from pathlib import Path +from typing import Generator import pytest -from testcontainers.compose import DockerCompose + +from tests.helpers import ( + MB, + LoadTestMetrics, + generate_random_file, + restart_icap_container, + start_icap_container, + stop_icap_container, + track_memory, +) + +try: + from testcontainers.compose import DockerCompose + + HAS_TESTCONTAINERS = True +except ImportError: + HAS_TESTCONTAINERS = False + DockerCompose = None # type: ignore[misc, assignment] def is_docker_available() -> tuple[bool, str]: @@ -40,6 +59,18 @@ def is_docker_available() -> tuple[bool, str]: return True, "Docker is available" +def is_icap_service_running(host: str, port: int) -> bool: + """Check if ICAP service is already running by attempting a TCP connection.""" + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(2) + result = sock.connect_ex((host, port)) + sock.close() + return result == 0 + except Exception: + return False + + def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: """Apply timeout to integration tests to allow for Docker startup.""" for item in items: @@ -88,14 +119,30 @@ def wait_for_icap_service( @pytest.fixture(scope="session") def icap_service(): - """Start ICAP service using docker-compose.""" + """Start ICAP service using docker-compose. + + If the service is already running (e.g., started by CI), uses the existing + containers. Otherwise, starts containers using testcontainers. + """ + config = {"host": "localhost", "port": 1344, "service": "avscan"} + + # Check if ICAP service is already running (e.g., started by CI) + if is_icap_service_running(config["host"], config["port"]): + # Service is already running, just wait for it to be fully ready + wait_for_icap_service(config["host"], config["port"], config["service"]) + yield config + return + # Check if Docker is available before attempting to start containers docker_available, message = is_docker_available() if not docker_available: pytest.skip(f"Skipping Docker-based tests: {message}") + # Check if testcontainers is available (requires Python 3.9+) + if not HAS_TESTCONTAINERS: + pytest.skip("Skipping Docker-based tests: testcontainers requires Python 3.9+") + docker_path = Path(__file__).parent.parent / "docker" - config = {"host": "localhost", "port": 1344, "service": "avscan"} with DockerCompose(str(docker_path), compose_file_name="docker-compose.yml"): # Wait for ICAP service to be ready (polls until OPTIONS succeeds) @@ -130,3 +177,147 @@ def icap_service_ssl(icap_service): "ssl_context": ssl_context, "ca_cert": str(ca_cert_path), } + + +# ============================================================================= +# Large File Fixtures +# ============================================================================= + + +@pytest.fixture +def large_file_10mb(tmp_path: Path) -> Generator[Path, None, None]: + """Generate a 10MB file with random data for testing. + + The file is automatically cleaned up after the test. + + Yields: + Path to the 10MB test file + """ + file_path = tmp_path / "test_10mb.bin" + generate_random_file(10 * MB, file_path) + yield file_path + # Cleanup handled by tmp_path fixture + + +@pytest.fixture +def large_file_100mb(tmp_path: Path) -> Generator[Path, None, None]: + """Generate a 100MB file with random data for testing. + + The file is automatically cleaned up after the test. + This fixture is slow - tests using it should be marked with @pytest.mark.slow. + + Yields: + Path to the 100MB test file + """ + file_path = tmp_path / "test_100mb.bin" + generate_random_file(100 * MB, file_path) + yield file_path + # Cleanup handled by tmp_path fixture + + +@pytest.fixture +def large_file_factory(tmp_path: Path): + """Factory fixture for generating files of arbitrary size. + + Returns a callable that creates files of specified size. + + Example: + def test_custom_size(large_file_factory): + file_25mb = large_file_factory(25 * MB) + # use file_25mb + """ + created_files: list[Path] = [] + + def _create_file(size_bytes: int, name: str | None = None) -> Path: + if name is None: + name = f"test_{size_bytes // MB}mb.bin" + file_path = tmp_path / name + generate_random_file(size_bytes, file_path) + created_files.append(file_path) + return file_path + + yield _create_file + # Cleanup handled by tmp_path fixture + + +# ============================================================================= +# Memory Tracking Fixtures +# ============================================================================= + + +@pytest.fixture +def memory_tracker(): + """Provide memory tracking context manager. + + Returns the track_memory context manager for use in tests. + + Example: + def test_memory_usage(memory_tracker): + with memory_tracker() as stats: + # do work + pass + assert stats.peak_mb < 50 + """ + return track_memory + + +# ============================================================================= +# Load Test Fixtures +# ============================================================================= + + +@pytest.fixture +def load_metrics() -> LoadTestMetrics: + """Provide a fresh LoadTestMetrics instance for collecting test metrics. + + Example: + def test_load(load_metrics): + for i in range(100): + start = time.time() + try: + do_operation() + load_metrics.record_success((time.time() - start) * 1000) + except Exception as e: + load_metrics.record_failure(e) + assert load_metrics.success_rate > 0.95 + """ + return LoadTestMetrics() + + +# ============================================================================= +# Docker Control Fixtures +# ============================================================================= + + +@pytest.fixture +def docker_controller(): + """Provide Docker container control functions. + + Returns a namespace with start, stop, and restart functions. + + Example: + def test_reconnect(docker_controller, icap_service): + # Do initial scan + docker_controller.restart() + wait_for_icap_service(...) + # Do another scan + """ + + class DockerController: + """Docker container controller for tests.""" + + container_name = "python-icap-server" + + def restart(self) -> None: + """Restart the ICAP container.""" + restart_icap_container(self.container_name) + + def stop(self) -> None: + """Stop the ICAP container.""" + stop_icap_container(self.container_name) + + def start(self) -> None: + """Start the ICAP container.""" + start_icap_container(self.container_name) + + return DockerController() diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..ff3528f --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,316 @@ +"""Test helper utilities for integration tests. + +This module provides utilities for: +- Large file generation +- Memory tracking +- Docker container control +- Load test metrics collection +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import tracemalloc +from contextlib import contextmanager +from dataclasses import dataclass, field +from pathlib import Path +from typing import Generator + +# Size constants +KB = 1024 +MB = 1024 * KB +GB = 1024 * MB + + +def generate_random_bytes(size_bytes: int) -> bytes: + """Generate random bytes of specified size. + + Uses os.urandom for cryptographically random data that won't compress. + + Args: + size_bytes: Number of bytes to generate + + Returns: + Random bytes + """ + return os.urandom(size_bytes) + + +def generate_random_file(size_bytes: int, path: Path) -> Path: + """Generate a file with random binary data. + + Creates a file with non-compressible random data to stress test + file handling and streaming. + + Args: + size_bytes: Size of file to generate + path: Path where file should be created + + Returns: + Path to the created file + """ + # Write in chunks to avoid memory issues with very large files + chunk_size = min(size_bytes, 8 * MB) + remaining = size_bytes + + with open(path, "wb") as f: + while remaining > 0: + write_size = min(chunk_size, remaining) + f.write(os.urandom(write_size)) + remaining -= write_size + + return path + + +@dataclass +class MemoryStats: + """Memory usage statistics from tracking.""" + + peak_mb: float + """Peak memory usage in megabytes.""" + + start_mb: float + """Memory usage at start of tracking in megabytes.""" + + end_mb: float + """Memory usage at end of tracking in megabytes.""" + + @property + def growth_mb(self) -> float: + """Memory growth during tracking period.""" + return self.end_mb - self.start_mb + + +@contextmanager +def track_memory() -> Generator[MemoryStats, None, None]: + """Track memory usage within a context. + + Uses tracemalloc to measure peak memory allocation. + + Yields: + MemoryStats object (populated after context exits) + + Example: + with track_memory() as stats: + # do memory-intensive work + pass + print(f"Peak memory: {stats.peak_mb:.1f} MB") + """ + # Create stats object that will be populated + stats = MemoryStats(peak_mb=0.0, start_mb=0.0, end_mb=0.0) + + # Start tracking + tracemalloc.start() + current, _ = tracemalloc.get_traced_memory() + stats.start_mb = current / MB + + try: + yield stats + finally: + # Capture final stats + current, peak = tracemalloc.get_traced_memory() + stats.end_mb = current / MB + stats.peak_mb = peak / MB + tracemalloc.stop() + + +def restart_icap_container(container_name: str = "python-icap-server") -> None: + """Restart the ICAP Docker container. + + Args: + container_name: Name of the container to restart + + Raises: + subprocess.CalledProcessError: If restart fails + """ + subprocess.run( + ["docker", "restart", container_name], + check=True, + capture_output=True, + timeout=60, + ) + + +def stop_icap_container(container_name: str = "python-icap-server") -> None: + """Stop the ICAP Docker container. + + Args: + container_name: Name of the container to stop + + Raises: + subprocess.CalledProcessError: If stop fails + """ + subprocess.run( + ["docker", "stop", container_name], + check=True, + capture_output=True, + timeout=30, + ) + + +def start_icap_container(container_name: str = "python-icap-server") -> None: + """Start the ICAP Docker container. + + Args: + container_name: Name of the container to start + + Raises: + subprocess.CalledProcessError: If start fails + """ + subprocess.run( + ["docker", "start", container_name], + check=True, + capture_output=True, + timeout=60, + ) + + +def get_open_fd_count() -> int: + """Return count of open file descriptors for current process. + + Works on Linux and macOS. Returns -1 on unsupported platforms. + + Returns: + Number of open file descriptors, or -1 if unable to determine + """ + if sys.platform == "linux": + fd_path = Path(f"/proc/{os.getpid()}/fd") + if fd_path.exists(): + return len(list(fd_path.iterdir())) + elif sys.platform == "darwin": + # macOS: use lsof + try: + result = subprocess.run( + ["lsof", "-p", str(os.getpid())], + capture_output=True, + text=True, + timeout=10, + ) + # Count lines (minus header) + lines = result.stdout.strip().split("\n") + return max(0, len(lines) - 1) + except Exception: + pass + + return -1 + + +@dataclass +class LoadTestMetrics: + """Metrics collected during load tests.""" + + success_count: int = 0 + """Number of successful operations.""" + + failure_count: int = 0 + """Number of failed operations.""" + + latencies_ms: list[float] = field(default_factory=list) + """List of operation latencies in milliseconds.""" + + errors: list[str] = field(default_factory=list) + """List of error messages from failures.""" + + @property + def total_count(self) -> int: + """Total number of operations attempted.""" + return self.success_count + self.failure_count + + @property + def success_rate(self) -> float: + """Success rate as a fraction (0.0 to 1.0).""" + if self.total_count == 0: + return 0.0 + return self.success_count / self.total_count + + @property + def success_rate_percent(self) -> float: + """Success rate as a percentage (0 to 100).""" + return self.success_rate * 100 + + @property + def avg_latency_ms(self) -> float: + """Average latency in milliseconds.""" + if not self.latencies_ms: + return 0.0 + return sum(self.latencies_ms) / len(self.latencies_ms) + + @property + def min_latency_ms(self) -> float: + """Minimum latency in milliseconds.""" + if not self.latencies_ms: + return 0.0 + return min(self.latencies_ms) + + @property + def max_latency_ms(self) -> float: + """Maximum latency in milliseconds.""" + if not self.latencies_ms: + return 0.0 + return max(self.latencies_ms) + + @property + def p50_latency_ms(self) -> float: + """50th percentile (median) latency in milliseconds.""" + return self._percentile(50) + + @property + def p95_latency_ms(self) -> float: + """95th percentile latency in milliseconds.""" + return self._percentile(95) + + @property + def p99_latency_ms(self) -> float: + """99th percentile latency in milliseconds.""" + return self._percentile(99) + + def _percentile(self, p: float) -> float: + """Calculate percentile of latencies.""" + if not self.latencies_ms: + return 0.0 + sorted_latencies = sorted(self.latencies_ms) + index = int(len(sorted_latencies) * p / 100) + index = min(index, len(sorted_latencies) - 1) + return sorted_latencies[index] + + def record_success(self, latency_ms: float) -> None: + """Record a successful operation. + + Args: + latency_ms: Operation latency in milliseconds + """ + self.success_count += 1 + self.latencies_ms.append(latency_ms) + + def record_failure(self, error: Exception | str, latency_ms: float = 0.0) -> None: + """Record a failed operation. + + Args: + error: Exception or error message + latency_ms: Operation latency in milliseconds (if available) + """ + self.failure_count += 1 + if latency_ms > 0: + self.latencies_ms.append(latency_ms) + error_msg = str(error) if isinstance(error, Exception) else error + self.errors.append(error_msg) + + def summary(self) -> str: + """Return a human-readable summary of metrics.""" + lines = [ + f"Total: {self.total_count} operations", + f"Success: {self.success_count} ({self.success_rate_percent:.1f}%)", + f"Failures: {self.failure_count}", + ] + if self.latencies_ms: + lines.extend( + [ + f"Latency avg: {self.avg_latency_ms:.1f}ms", + f"Latency p50: {self.p50_latency_ms:.1f}ms", + f"Latency p99: {self.p99_latency_ms:.1f}ms", + f"Latency min/max: {self.min_latency_ms:.1f}ms / {self.max_latency_ms:.1f}ms", + ] + ) + return "\n".join(lines) diff --git a/tests/test_benchmarks.py b/tests/test_benchmarks.py new file mode 100644 index 0000000..89ad89a --- /dev/null +++ b/tests/test_benchmarks.py @@ -0,0 +1,228 @@ +""" +Performance benchmarks for ICAP client operations. + +These tests measure throughput and latency of ICAP operations against a real +c-icap/ClamAV server running in Docker. They use pytest-benchmark for +consistent measurement and reporting. + +Run benchmarks with: just benchmark +Or directly: pytest -m benchmark --benchmark-only + +Note: These benchmarks measure end-to-end performance including network I/O +and server processing time. The ICAP server (ClamAV) dominates the time for +actual virus scanning - client overhead is typically negligible. +""" + +import asyncio +import io +import os +import tempfile + +import pytest + +from icap import AsyncIcapClient, IcapClient + +# Standard EICAR test string for triggering virus detection +EICAR = b"X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*" + +# TODO: Some benchmarks are flaky in CI due to the Docker-based ICAP server returning +# invalid responses or unexpected redirects. The tests pass locally but fail intermittently +# in GitHub Actions. Investigation needed into the CI Docker environment configuration. +# See: https://github.com/CaptainDriftwood/python-icap/pull/42 +CI = os.environ.get("CI", "false").lower() == "true" + + +# ============================================================================= +# Sync Client Benchmarks +# ============================================================================= + + +@pytest.mark.benchmark +@pytest.mark.slow +@pytest.mark.docker +def test_benchmark_options_request(benchmark, icap_service): + """Benchmark OPTIONS request latency (includes connection overhead).""" + + def run_options(): + with IcapClient(icap_service["host"], icap_service["port"]) as client: + return client.options(icap_service["service"]) + + result = benchmark(run_options) + assert result.is_success + + +@pytest.mark.benchmark +@pytest.mark.slow +@pytest.mark.docker +def test_benchmark_scan_small_clean(benchmark, icap_service): + """Benchmark scanning small clean content (1 KB).""" + content = b"Clean content " * 73 # ~1 KB + + def run_scan(): + with IcapClient(icap_service["host"], icap_service["port"]) as client: + return client.scan_bytes(content, service=icap_service["service"]) + + result = benchmark(run_scan) + assert result.is_no_modification + + +@pytest.mark.benchmark +@pytest.mark.slow +@pytest.mark.docker +def test_benchmark_scan_medium_clean(benchmark, icap_service): + """Benchmark scanning medium clean content (100 KB).""" + content = b"Clean content " * 7300 # ~100 KB + + def run_scan(): + with IcapClient(icap_service["host"], icap_service["port"]) as client: + return client.scan_bytes(content, service=icap_service["service"]) + + result = benchmark(run_scan) + assert result.is_no_modification + + +@pytest.mark.benchmark +@pytest.mark.slow +@pytest.mark.docker +def test_benchmark_scan_large_clean(benchmark, icap_service): + """Benchmark scanning large clean content (1 MB).""" + content = b"Clean content " * 73000 # ~1 MB + + def run_scan(): + with IcapClient(icap_service["host"], icap_service["port"]) as client: + return client.scan_bytes(content, service=icap_service["service"]) + + result = benchmark(run_scan) + assert result.is_no_modification + + +@pytest.mark.benchmark +@pytest.mark.slow +@pytest.mark.docker +def test_benchmark_scan_very_large_clean(benchmark, icap_service): + """Benchmark scanning very large clean content (10 MB).""" + content = b"Clean content " * 730000 # ~10 MB + + def run_scan(): + with IcapClient(icap_service["host"], icap_service["port"]) as client: + return client.scan_bytes(content, service=icap_service["service"]) + + result = benchmark(run_scan) + assert result.is_no_modification + + +@pytest.mark.benchmark +@pytest.mark.slow +@pytest.mark.docker +def test_benchmark_scan_virus_detection(benchmark, icap_service): + """Benchmark virus detection latency.""" + + def run_scan(): + with IcapClient(icap_service["host"], icap_service["port"]) as client: + return client.scan_bytes(EICAR, service=icap_service["service"]) + + result = benchmark(run_scan) + # EICAR should be detected - not a 204 response + assert not result.is_no_modification + + +@pytest.mark.benchmark +@pytest.mark.slow +@pytest.mark.docker +def test_benchmark_scan_file(benchmark, icap_service): + """Benchmark file scanning (1 MB file).""" + content = b"Clean file content " * 55000 # ~1 MB + with tempfile.NamedTemporaryFile(delete=False, suffix=".bin") as f: + f.write(content) + filepath = f.name + + def run_scan(): + with IcapClient(icap_service["host"], icap_service["port"]) as client: + return client.scan_file(filepath, service=icap_service["service"]) + + try: + result = benchmark(run_scan) + assert result.is_no_modification + finally: + os.unlink(filepath) + + +@pytest.mark.benchmark +@pytest.mark.slow +@pytest.mark.docker +def test_benchmark_scan_stream(benchmark, icap_service): + """Benchmark stream scanning (1 MB stream).""" + content = b"Clean stream content " * 50000 # ~1 MB + + def run_scan(): + stream = io.BytesIO(content) + with IcapClient(icap_service["host"], icap_service["port"]) as client: + return client.scan_stream(stream, service=icap_service["service"]) + + result = benchmark(run_scan) + assert result.is_no_modification + + +@pytest.mark.benchmark +@pytest.mark.slow +@pytest.mark.docker +@pytest.mark.skipif(CI, reason="Flaky in CI: receives invalid responses from Docker ICAP server") +def test_benchmark_connection_reuse(benchmark, icap_service): + """Benchmark multiple scans on a single connection (amortized connection cost).""" + content = b"Clean content " * 73 # ~1 KB + + def scan_multiple(): + with IcapClient(icap_service["host"], icap_service["port"]) as client: + for _ in range(5): + client.scan_bytes(content, service=icap_service["service"]) + + benchmark(scan_multiple) + + +# ============================================================================= +# Async Client Benchmarks +# ============================================================================= + + +@pytest.mark.benchmark +@pytest.mark.slow +@pytest.mark.docker +def test_benchmark_async_options_request(benchmark, icap_service): + """Benchmark async OPTIONS request latency (includes connection overhead).""" + + async def run_options(): + async with AsyncIcapClient(icap_service["host"], icap_service["port"]) as client: + return await client.options(icap_service["service"]) + + result = benchmark(lambda: asyncio.run(run_options())) + assert result.is_success + + +@pytest.mark.benchmark +@pytest.mark.slow +@pytest.mark.docker +def test_benchmark_async_scan_medium_clean(benchmark, icap_service): + """Benchmark async scanning medium clean content (100 KB).""" + content = b"Clean content " * 7300 # ~100 KB + + async def run_scan(): + async with AsyncIcapClient(icap_service["host"], icap_service["port"]) as client: + return await client.scan_bytes(content, service=icap_service["service"]) + + result = benchmark(lambda: asyncio.run(run_scan())) + assert result.is_no_modification + + +@pytest.mark.benchmark +@pytest.mark.slow +@pytest.mark.docker +def test_benchmark_async_scan_large_clean(benchmark, icap_service): + """Benchmark async scanning large clean content (1 MB).""" + content = b"Clean content " * 73000 # ~1 MB + + async def run_scan(): + async with AsyncIcapClient(icap_service["host"], icap_service["port"]) as client: + return await client.scan_bytes(content, service=icap_service["service"]) + + result = benchmark(lambda: asyncio.run(run_scan())) + assert result.is_no_modification diff --git a/tests/test_client_methods.py b/tests/test_client_methods.py index 43b233b..03ab55d 100644 --- a/tests/test_client_methods.py +++ b/tests/test_client_methods.py @@ -94,12 +94,11 @@ def test_send_with_preview_connection_error(): mock_socket.sendall.side_effect = OSError("Connection reset") client._socket = mock_socket - client._connected = True with pytest.raises(IcapConnectionError): client._send_with_preview(b"request", b"body", preview_size=10) - assert not client._connected + assert not client.is_connected def test_receive_response_simple(): @@ -432,10 +431,10 @@ async def test_async_receive_response_simple(): ] client._reader = mock_reader - client._connected = True - response_data = await client._receive_response() - assert b"200 OK" in response_data + response = await client._receive_response() + assert response.status_code == 200 + assert response.status_message == "OK" @pytest.mark.asyncio @@ -644,7 +643,8 @@ async def test_async_options_basic(): """Test basic async OPTIONS request.""" client = AsyncIcapClient("localhost", 1344) - mock_writer = AsyncMock() + mock_writer = MagicMock() + mock_writer.drain = AsyncMock() mock_reader = AsyncMock() mock_reader.read.side_effect = [ b"ICAP/1.0 200 OK\r\nMethods: RESPMOD, REQMOD\r\nAllow: 204\r\nPreview: 1024\r\n\r\n", @@ -670,7 +670,8 @@ async def test_async_options_auto_connects(mocker): client = AsyncIcapClient("localhost", 1344) client._connected = False - mock_writer = AsyncMock() + mock_writer = MagicMock() + mock_writer.drain = AsyncMock() mock_reader = AsyncMock() mock_reader.read.side_effect = [b"ICAP/1.0 200 OK\r\n\r\n", b""] @@ -692,7 +693,8 @@ async def test_async_respmod_basic(): """Test basic async RESPMOD request.""" client = AsyncIcapClient("localhost", 1344) - mock_writer = AsyncMock() + mock_writer = MagicMock() + mock_writer.drain = AsyncMock() mock_reader = AsyncMock() mock_reader.read.side_effect = [ b"ICAP/1.0 200 OK\r\nEncapsulated: res-hdr=0\r\n\r\n", @@ -718,7 +720,8 @@ async def test_async_reqmod_basic(): """Test basic async REQMOD request.""" client = AsyncIcapClient("localhost", 1344) - mock_writer = AsyncMock() + mock_writer = MagicMock() + mock_writer.drain = AsyncMock() mock_reader = AsyncMock() mock_reader.read.side_effect = [ b"ICAP/1.0 200 OK\r\n\r\n", @@ -743,7 +746,8 @@ async def test_async_scan_bytes_basic(): """Test async scan_bytes method.""" client = AsyncIcapClient("localhost", 1344) - mock_writer = AsyncMock() + mock_writer = MagicMock() + mock_writer.drain = AsyncMock() mock_reader = AsyncMock() mock_reader.read.side_effect = [ b"ICAP/1.0 204 No Content\r\n\r\n", @@ -765,7 +769,8 @@ async def test_async_scan_bytes_with_filename(): """Test async scan_bytes with custom filename.""" client = AsyncIcapClient("localhost", 1344) - mock_writer = AsyncMock() + mock_writer = MagicMock() + mock_writer.drain = AsyncMock() mock_reader = AsyncMock() mock_reader.read.side_effect = [ b"ICAP/1.0 204 No Content\r\n\r\n", @@ -792,7 +797,8 @@ async def test_async_scan_file_basic(mocker, tmp_path): client = AsyncIcapClient("localhost", 1344) - mock_writer = AsyncMock() + mock_writer = MagicMock() + mock_writer.drain = AsyncMock() mock_reader = AsyncMock() mock_reader.read.side_effect = [ b"ICAP/1.0 204 No Content\r\n\r\n", @@ -824,7 +830,8 @@ async def test_async_send_with_preview_complete_in_preview(): """Test async preview mode when entire body fits in preview size.""" client = AsyncIcapClient("localhost", 1344) - mock_writer = AsyncMock() + mock_writer = MagicMock() + mock_writer.drain = AsyncMock() mock_reader = AsyncMock() # Server responds with 204 (no modification needed) mock_reader.read.side_effect = [ @@ -854,7 +861,8 @@ async def test_async_send_with_preview_requires_continue(): """Test async preview mode when server requests remaining body.""" client = AsyncIcapClient("localhost", 1344) - mock_writer = AsyncMock() + mock_writer = MagicMock() + mock_writer.drain = AsyncMock() mock_reader = AsyncMock() # First response: 100 Continue, then 204 No Content mock_reader.read.side_effect = [ @@ -1091,9 +1099,9 @@ async def test_async_receive_response_empty_on_first_read(): client._reader = mock_reader - # This should not raise, just return empty response data - response_data = await client._receive_response() - assert response_data == b"" + # Empty response data fails to parse as valid ICAP response + with pytest.raises(ValueError, match="Invalid ICAP status line"): + await client._receive_response() @pytest.mark.asyncio @@ -1215,3 +1223,443 @@ def test_scan_file_uses_filename(tmp_path): assert response.status_code == 204 sent_data = mock_socket.sendall.call_args[0][0] assert b"report.pdf" in sent_data + + +# ============================================================================= +# Security: max_response_size tests +# ============================================================================= + + +def test_max_response_size_default(): + """Test that max_response_size has a sensible default (100MB).""" + client = IcapClient("localhost", 1344) + assert client._max_response_size == 104_857_600 # 100MB + + +def test_max_response_size_custom(): + """Test that max_response_size can be customized.""" + client = IcapClient("localhost", 1344, max_response_size=500_000_000) + assert client._max_response_size == 500_000_000 + + +def test_max_response_size_must_be_positive(): + """Test that max_response_size must be a positive integer.""" + with pytest.raises(ValueError, match="must be a positive integer"): + IcapClient("localhost", 1344, max_response_size=0) + + with pytest.raises(ValueError, match="must be a positive integer"): + IcapClient("localhost", 1344, max_response_size=-1) + + +def test_content_length_exceeds_max_response_size(): + """Test that Content-Length exceeding max_response_size raises error.""" + client = IcapClient("localhost", 1344, max_response_size=1000) + + mock_socket = MagicMock() + # Server claims response is 10000 bytes, exceeds our 1000 byte limit + mock_socket.recv.return_value = b"ICAP/1.0 200 OK\r\nContent-Length: 10000\r\n\r\n" + + client._socket = mock_socket + client._connected = True + + with pytest.raises(IcapProtocolError, match="exceeds maximum allowed size"): + client._receive_response() + + +def test_chunk_size_exceeds_max_response_size(): + """Test that a chunk size exceeding max_response_size raises error.""" + client = IcapClient("localhost", 1344, max_response_size=1000) + + mock_socket = MagicMock() + client._socket = mock_socket + client._connected = True + + # Chunk claiming to be 0xFFFF (65535) bytes, exceeds our 1000 byte limit + initial_data = b"FFFF\r\n" + + with pytest.raises(IcapProtocolError, match="Chunk size.*exceeds maximum allowed size"): + client._read_chunked_body(initial_data) + + +def test_cumulative_chunked_body_exceeds_max_response_size(): + """Test that cumulative chunked body exceeding max_response_size raises error.""" + client = IcapClient("localhost", 1344, max_response_size=100) + + mock_socket = MagicMock() + # Each chunk is small (50 bytes), but together they exceed 100 bytes + mock_socket.recv.side_effect = [ + b"A" * 50 + b"\r\n", # Complete first chunk data + b"32\r\n", # Second chunk header (50 bytes in hex) + b"B" * 50 + b"\r\n", # Second chunk data - total now 100 bytes + b"32\r\n", # Third chunk header + b"C" * 50 + b"\r\n", # Third chunk data - would exceed limit + ] + + client._socket = mock_socket + client._connected = True + + # First chunk is 50 bytes (0x32 in hex) + initial_data = b"32\r\n" + + with pytest.raises(IcapProtocolError, match="Chunked response body.*exceeds maximum"): + client._read_chunked_body(initial_data) + + +async def test_async_max_response_size_default(): + """Test that async client max_response_size has a sensible default (100MB).""" + client = AsyncIcapClient("localhost", 1344) + assert client._max_response_size == 104_857_600 # 100MB + + +async def test_async_max_response_size_custom(): + """Test that async client max_response_size can be customized.""" + client = AsyncIcapClient("localhost", 1344, max_response_size=500_000_000) + assert client._max_response_size == 500_000_000 + + +async def test_async_max_response_size_must_be_positive(): + """Test that async client max_response_size must be a positive integer.""" + with pytest.raises(ValueError, match="must be a positive integer"): + AsyncIcapClient("localhost", 1344, max_response_size=0) + + with pytest.raises(ValueError, match="must be a positive integer"): + AsyncIcapClient("localhost", 1344, max_response_size=-1) + + +# ============================================================================= +# Header validation tests (CRLF injection prevention) +# ============================================================================= + + +def test_header_name_with_crlf_rejected(): + """Header names with CRLF should be rejected.""" + client = IcapClient("localhost", 1344) + + with pytest.raises(ValueError, match="Invalid header name"): + client._build_request( + "OPTIONS icap://localhost/test ICAP/1.0\r\n", {"X-Bad\r\nHeader": "value"} + ) + + +def test_header_value_with_crlf_rejected(): + """Header values with CRLF should be rejected.""" + client = IcapClient("localhost", 1344) + + with pytest.raises(ValueError, match="Invalid header value"): + client._build_request( + "OPTIONS icap://localhost/test ICAP/1.0\r\n", {"X-Header": "bad\r\nvalue"} + ) + + +def test_header_name_with_newline_rejected(): + """Header names with newline should be rejected.""" + client = IcapClient("localhost", 1344) + + with pytest.raises(ValueError, match="Invalid header name"): + client._build_request( + "OPTIONS icap://localhost/test ICAP/1.0\r\n", {"X-Bad\nHeader": "value"} + ) + + +def test_header_value_with_newline_rejected(): + """Header values with newline should be rejected.""" + client = IcapClient("localhost", 1344) + + with pytest.raises(ValueError, match="Invalid header value"): + client._build_request( + "OPTIONS icap://localhost/test ICAP/1.0\r\n", {"X-Header": "bad\nvalue"} + ) + + +def test_header_name_empty_rejected(): + """Empty header names should be rejected.""" + client = IcapClient("localhost", 1344) + + with pytest.raises(ValueError, match="Header name cannot be empty"): + client._build_request("OPTIONS icap://localhost/test ICAP/1.0\r\n", {"": "value"}) + + +def test_header_name_with_space_rejected(): + """Header names with spaces should be rejected.""" + client = IcapClient("localhost", 1344) + + with pytest.raises(ValueError, match="Invalid header name"): + client._build_request( + "OPTIONS icap://localhost/test ICAP/1.0\r\n", {"X Bad Header": "value"} + ) + + +def test_header_name_with_colon_rejected(): + """Header names with colons should be rejected.""" + client = IcapClient("localhost", 1344) + + with pytest.raises(ValueError, match="Invalid header name"): + client._build_request("OPTIONS icap://localhost/test ICAP/1.0\r\n", {"X:Header": "value"}) + + +def test_valid_headers_accepted(): + """Valid headers should be accepted.""" + client = IcapClient("localhost", 1344) + + # Should not raise + result = client._build_request( + "OPTIONS icap://localhost/test ICAP/1.0\r\n", + {"X-Custom-Header": "valid value with spaces", "Another-Header": "123"}, + ) + assert b"X-Custom-Header: valid value with spaces" in result + assert b"Another-Header: 123" in result + + +def test_header_value_with_tab_accepted(): + """Header values with horizontal tabs should be accepted.""" + client = IcapClient("localhost", 1344) + + # HTAB is allowed in header values + result = client._build_request( + "OPTIONS icap://localhost/test ICAP/1.0\r\n", {"X-Header": "value\twith\ttabs"} + ) + assert b"X-Header: value\twith\ttabs" in result + + +# ============================================================================= +# Header section size limit tests +# ============================================================================= + + +def test_header_section_size_limit_sync(): + """Test that oversized header sections are rejected (sync client).""" + client = IcapClient("localhost", 1344) + + mock_socket = MagicMock() + # Return data that looks like headers but never ends (no \r\n\r\n) + # Each call returns 8KB of header-like data + mock_socket.recv.return_value = b"X-Header: " + b"A" * 8000 + b"\r\n" + + client._socket = mock_socket + client._connected = True + + with pytest.raises(IcapProtocolError, match="header section exceeds maximum size"): + client._receive_response() + + +async def test_header_section_size_limit_async(): + """Test that oversized header sections are rejected (async client).""" + client = AsyncIcapClient("localhost", 1344) + + mock_reader = AsyncMock() + # Return data that looks like headers but never ends + mock_reader.read.return_value = b"X-Header: " + b"A" * 8000 + b"\r\n" + + client._reader = mock_reader + client._writer = MagicMock() + + with pytest.raises(IcapProtocolError, match="header section exceeds maximum size"): + await client._receive_response() + + +# ============================================================================= +# Preview mode edge case tests +# ============================================================================= + + +def test_preview_body_equals_preview_size(): + """Test preview mode when body size exactly equals preview size.""" + client = IcapClient("localhost", 1344) + + mock_socket = MagicMock() + mock_socket.recv.return_value = b"ICAP/1.0 204 No Content\r\n\r\n" + + client._socket = mock_socket + client._connected = True + + request = b"RESPMOD icap://localhost:1344/avscan ICAP/1.0\r\n\r\n" + body = b"exactly10!" # Exactly 10 bytes + + response = client._send_with_preview(request, body, preview_size=10) + + assert response.status_code == 204 + # When body == preview size, should use ieof (complete in preview) + sent_data = mock_socket.sendall.call_args[0][0] + assert b"ieof" in sent_data + + +def test_preview_body_smaller_than_preview_size(): + """Test preview mode when body size is less than preview size.""" + client = IcapClient("localhost", 1344) + + mock_socket = MagicMock() + mock_socket.recv.return_value = b"ICAP/1.0 204 No Content\r\n\r\n" + + client._socket = mock_socket + client._connected = True + + request = b"RESPMOD icap://localhost:1344/avscan ICAP/1.0\r\n\r\n" + body = b"tiny" # 4 bytes, less than preview of 100 + + response = client._send_with_preview(request, body, preview_size=100) + + assert response.status_code == 204 + # Small body should use ieof + sent_data = mock_socket.sendall.call_args[0][0] + assert b"ieof" in sent_data + + +def test_preview_server_rejects_early(): + """Test preview mode when server rejects content early (200/403 response).""" + client = IcapClient("localhost", 1344) + + mock_socket = MagicMock() + # Server responds with 200 (threat detected) instead of 100 Continue + mock_socket.recv.return_value = ( + b"ICAP/1.0 200 OK\r\nX-Virus-ID: EICAR\r\nEncapsulated: res-hdr=0, res-body=45\r\n\r\n" + ) + + client._socket = mock_socket + client._connected = True + + request = b"RESPMOD icap://localhost:1344/avscan ICAP/1.0\r\n\r\n" + body = b"X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*" + + response = client._send_with_preview(request, body, preview_size=20) + + # Server should return 200 without needing the rest of the body + assert response.status_code == 200 + assert response.headers.get("X-Virus-ID") == "EICAR" + + +async def test_async_preview_complete_in_preview(): + """Test async preview mode when entire body fits in preview size.""" + client = AsyncIcapClient("localhost", 1344) + + mock_reader = AsyncMock() + mock_reader.read.return_value = b"ICAP/1.0 204 No Content\r\n\r\n" + + mock_writer = MagicMock() + mock_writer.drain = AsyncMock() + + client._reader = mock_reader + client._writer = mock_writer + + request = b"RESPMOD icap://localhost:1344/avscan ICAP/1.0\r\n\r\n" + body = b"small" # 5 bytes, fits in preview of 10 + + response = await client._send_with_preview(request, body, preview_size=10) + + assert response.status_code == 204 + # Verify ieof was sent + sent_data = mock_writer.write.call_args[0][0] + assert b"ieof" in sent_data + + +async def test_async_preview_requires_continue(): + """Test async preview mode when server requests remainder with 100 Continue.""" + client = AsyncIcapClient("localhost", 1344) + + mock_reader = AsyncMock() + mock_reader.read.side_effect = [ + b"ICAP/1.0 100 Continue\r\n\r\n", + b"ICAP/1.0 204 No Content\r\n\r\n", + ] + + mock_writer = MagicMock() + mock_writer.drain = AsyncMock() + + client._reader = mock_reader + client._writer = mock_writer + + request = b"RESPMOD icap://localhost:1344/avscan ICAP/1.0\r\n\r\n" + body = b"a" * 100 # 100 bytes, more than preview of 10 + + response = await client._send_with_preview(request, body, preview_size=10) + + assert response.status_code == 204 + # Should have called write multiple times (preview, then remainder) + assert mock_writer.write.call_count >= 2 + + +# ============================================================================= +# HTTP encapsulation edge case tests +# ============================================================================= + + +def test_http_response_without_body(): + """Test RESPMOD with HTTP response that has no body.""" + client = IcapClient("localhost", 1344) + + mock_socket = MagicMock() + mock_socket.recv.return_value = b"ICAP/1.0 204 No Content\r\n\r\n" + + client._socket = mock_socket + client._connected = True + + http_request = b"GET /empty HTTP/1.1\r\nHost: test\r\n\r\n" + # HTTP response with Content-Length: 0 + http_response = b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n" + + response = client.respmod("avscan", http_request, http_response) + + assert response.status_code == 204 + + +def test_http_response_headers_only(): + """Test RESPMOD with HTTP response that is headers only (no body separator).""" + client = IcapClient("localhost", 1344) + + mock_socket = MagicMock() + mock_socket.recv.return_value = b"ICAP/1.0 204 No Content\r\n\r\n" + + client._socket = mock_socket + client._connected = True + + http_request = b"GET /test HTTP/1.1\r\nHost: test\r\n\r\n" + # HTTP response without \r\n\r\n (no body separator) + http_response = b"HTTP/1.1 200 OK\r\nContent-Type: text/html" + + response = client.respmod("avscan", http_request, http_response) + + assert response.status_code == 204 + + +def test_http_body_with_embedded_crlf(): + """Test RESPMOD with binary HTTP body containing CRLF sequences.""" + client = IcapClient("localhost", 1344) + + mock_socket = MagicMock() + mock_socket.recv.return_value = b"ICAP/1.0 204 No Content\r\n\r\n" + + client._socket = mock_socket + client._connected = True + + http_request = b"GET /binary HTTP/1.1\r\nHost: test\r\n\r\n" + # Binary body with embedded CRLF - should not confuse the parser + binary_body = b"binary\r\n\r\ndata\r\nwith\r\n\r\nmany\r\ncrlf" + http_response = ( + b"HTTP/1.1 200 OK\r\n" + b"Content-Type: application/octet-stream\r\n" + b"Content-Length: " + str(len(binary_body)).encode() + b"\r\n" + b"\r\n" + binary_body + ) + + response = client.respmod("avscan", http_request, http_response) + + assert response.status_code == 204 + + +def test_large_http_headers(): + """Test RESPMOD with large HTTP headers (> 8KB).""" + client = IcapClient("localhost", 1344) + + mock_socket = MagicMock() + mock_socket.recv.return_value = b"ICAP/1.0 204 No Content\r\n\r\n" + + client._socket = mock_socket + client._connected = True + + http_request = b"GET /test HTTP/1.1\r\nHost: test\r\n\r\n" + # HTTP response with a very large header + large_header = b"X-Large-Header: " + b"A" * 10000 + b"\r\n" + http_response = b"HTTP/1.1 200 OK\r\n" + large_header + b"Content-Length: 5\r\n\r\nhello" + + response = client.respmod("avscan", http_request, http_response) + + assert response.status_code == 204 diff --git a/tests/test_concurrent_load.py b/tests/test_concurrent_load.py new file mode 100644 index 0000000..9edbe58 --- /dev/null +++ b/tests/test_concurrent_load.py @@ -0,0 +1,378 @@ +"""Concurrent load testing for ICAP client. + +These tests verify the client handles high concurrency correctly, +including resource management and graceful error handling. + +All tests require Docker (c-icap + ClamAV) to be running. +""" + +from __future__ import annotations + +import asyncio +import time +from pathlib import Path + +import pytest + +from icap import AsyncIcapClient, IcapClient +from icap.exception import IcapConnectionError, IcapException +from tests.helpers import KB, MB, LoadTestMetrics, get_open_fd_count + + +@pytest.mark.integration +@pytest.mark.docker +@pytest.mark.slow +async def test_50_concurrent_scans(icap_service, load_metrics: LoadTestMetrics): + """Test 50 simultaneous async scans. + + All scans should complete successfully with >95% success rate. + """ + content = b"Clean test content for concurrent scanning" + num_scans = 50 + + async def scan_one(scan_id: int) -> None: + """Perform a single scan and record metrics.""" + start = time.perf_counter() + try: + async with AsyncIcapClient(icap_service["host"], icap_service["port"]) as client: + response = await client.scan_bytes( + content, + service=icap_service["service"], + filename=f"concurrent_{scan_id}.txt", + ) + if response.is_no_modification or response.is_success: + latency_ms = (time.perf_counter() - start) * 1000 + load_metrics.record_success(latency_ms) + else: + load_metrics.record_failure(f"Unexpected status: {response.status_code}") + except Exception as e: + latency_ms = (time.perf_counter() - start) * 1000 + load_metrics.record_failure(e, latency_ms) + + # Launch all scans concurrently + await asyncio.gather(*[scan_one(i) for i in range(num_scans)]) + + # Verify results + assert load_metrics.success_rate >= 0.95, ( + f"Success rate {load_metrics.success_rate_percent:.1f}% below 95% threshold.\n" + f"{load_metrics.summary()}" + ) + + +@pytest.mark.integration +@pytest.mark.docker +@pytest.mark.slow +async def test_100_concurrent_scans(icap_service, load_metrics: LoadTestMetrics): + """Test 100 simultaneous scans with graceful error handling. + + Some failures are expected due to server limits (MaxServers=10). + Test verifies graceful handling rather than 100% success. + """ + content = b"Clean test content" + num_scans = 100 + + async def scan_one(scan_id: int) -> None: + """Perform a single scan and record metrics.""" + start = time.perf_counter() + try: + async with AsyncIcapClient( + icap_service["host"], + icap_service["port"], + timeout=30, + ) as client: + response = await client.scan_bytes( + content, + service=icap_service["service"], + filename=f"load_{scan_id}.txt", + ) + if response.is_no_modification or response.is_success: + latency_ms = (time.perf_counter() - start) * 1000 + load_metrics.record_success(latency_ms) + else: + load_metrics.record_failure(f"Status: {response.status_code}") + except (IcapConnectionError, IcapException, asyncio.TimeoutError) as e: + # Expected failures under load + latency_ms = (time.perf_counter() - start) * 1000 + load_metrics.record_failure(e, latency_ms) + except Exception as e: + # Unexpected failures + latency_ms = (time.perf_counter() - start) * 1000 + load_metrics.record_failure(f"Unexpected: {type(e).__name__}: {e}", latency_ms) + + # Launch all scans concurrently + await asyncio.gather(*[scan_one(i) for i in range(num_scans)]) + + # At least some should succeed, and no crashes + assert load_metrics.success_count > 0, "No scans succeeded" + # With 100 concurrent and MaxServers=10, expect at least 50% success + assert load_metrics.success_rate >= 0.50, ( + f"Success rate {load_metrics.success_rate_percent:.1f}% unexpectedly low.\n" + f"{load_metrics.summary()}" + ) + + +@pytest.mark.integration +@pytest.mark.docker +async def test_mixed_workload(icap_service, load_metrics: LoadTestMetrics): + """Test concurrent mixed workload: OPTIONS + scans + REQMOD. + + Verifies different ICAP methods work correctly under concurrent load. + """ + content = b"Test content for mixed workload" + + async def do_options(task_id: int) -> None: + start = time.perf_counter() + try: + async with AsyncIcapClient(icap_service["host"], icap_service["port"]) as client: + response = await client.options(icap_service["service"]) + if response.is_success: + load_metrics.record_success((time.perf_counter() - start) * 1000) + else: + load_metrics.record_failure(f"OPTIONS failed: {response.status_code}") + except Exception as e: + load_metrics.record_failure(e) + + async def do_scan(task_id: int) -> None: + start = time.perf_counter() + try: + async with AsyncIcapClient(icap_service["host"], icap_service["port"]) as client: + response = await client.scan_bytes( + content, + service=icap_service["service"], + filename=f"mixed_{task_id}.txt", + ) + if response.is_no_modification or response.is_success: + load_metrics.record_success((time.perf_counter() - start) * 1000) + else: + load_metrics.record_failure(f"Scan failed: {response.status_code}") + except Exception as e: + load_metrics.record_failure(e) + + async def do_reqmod(task_id: int) -> None: + start = time.perf_counter() + try: + async with AsyncIcapClient(icap_service["host"], icap_service["port"]) as client: + http_request = b"POST /upload HTTP/1.1\r\nHost: example.com\r\n\r\n" + response = await client.reqmod( + icap_service["service"], + http_request, + http_body=content, + ) + if response.is_no_modification or response.is_success: + load_metrics.record_success((time.perf_counter() - start) * 1000) + else: + load_metrics.record_failure(f"REQMOD failed: {response.status_code}") + except Exception as e: + load_metrics.record_failure(e) + + # Create mixed workload: 10 OPTIONS + 20 scans + 5 REQMOD + tasks = [] + tasks.extend([do_options(i) for i in range(10)]) + tasks.extend([do_scan(i) for i in range(20)]) + tasks.extend([do_reqmod(i) for i in range(5)]) + + # Shuffle and run concurrently + import random + + random.shuffle(tasks) + await asyncio.gather(*tasks) + + # All operations should succeed + assert load_metrics.success_rate >= 0.90, ( + f"Mixed workload success rate {load_metrics.success_rate_percent:.1f}% below 90%.\n" + f"{load_metrics.summary()}" + ) + + +@pytest.mark.integration +@pytest.mark.docker +@pytest.mark.slow +async def test_sustained_load_30s(icap_service, load_metrics: LoadTestMetrics): + """Test sustained load for 30 seconds. + + Continuously sends scans for 30 seconds to verify no resource growth. + """ + content = b"Sustained load test content" + duration_seconds = 30 + max_concurrent = 5 + + async def scan_worker(worker_id: int, stop_event: asyncio.Event) -> None: + """Worker that continuously scans until stopped.""" + while not stop_event.is_set(): + start = time.perf_counter() + try: + async with AsyncIcapClient( + icap_service["host"], icap_service["port"], timeout=10 + ) as client: + response = await client.scan_bytes( + content, + service=icap_service["service"], + filename=f"sustained_{worker_id}.txt", + ) + if response.is_no_modification or response.is_success: + load_metrics.record_success((time.perf_counter() - start) * 1000) + else: + load_metrics.record_failure(f"Status: {response.status_code}") + except asyncio.CancelledError: + break + except Exception as e: + load_metrics.record_failure(e) + await asyncio.sleep(0.1) # Brief pause on error + + stop_event = asyncio.Event() + workers = [scan_worker(i, stop_event) for i in range(max_concurrent)] + + # Run workers for specified duration + worker_tasks = [asyncio.create_task(w) for w in workers] + await asyncio.sleep(duration_seconds) + stop_event.set() + + # Wait for workers to finish + await asyncio.gather(*worker_tasks, return_exceptions=True) + + # Verify reasonable throughput and success rate + assert load_metrics.total_count > 10, "Too few operations completed" + assert load_metrics.success_rate >= 0.90, ( + f"Sustained load success rate {load_metrics.success_rate_percent:.1f}% below 90%.\n" + f"{load_metrics.summary()}" + ) + + +@pytest.mark.integration +@pytest.mark.docker +async def test_concurrent_varied_sizes(icap_service, large_file_factory, load_metrics): + """Test concurrent scans with varied file sizes (1KB to 1MB).""" + + async def scan_file(file_path: Path) -> None: + start = time.perf_counter() + try: + async with AsyncIcapClient(icap_service["host"], icap_service["port"]) as client: + response = await client.scan_file(file_path, service=icap_service["service"]) + if response.is_no_modification or response.is_success: + load_metrics.record_success((time.perf_counter() - start) * 1000) + else: + load_metrics.record_failure(f"Status: {response.status_code}") + except Exception as e: + load_metrics.record_failure(e) + + # Create files of varied sizes + sizes = [1 * KB, 10 * KB, 100 * KB, 500 * KB, 1 * MB] + files = [large_file_factory(size, f"varied_{size}.bin") for size in sizes] + + # Scan each file twice concurrently (10 total scans) + tasks = [scan_file(f) for f in files * 2] + await asyncio.gather(*tasks) + + assert load_metrics.success_rate >= 0.90, ( + f"Varied sizes success rate {load_metrics.success_rate_percent:.1f}% below 90%.\n" + f"{load_metrics.summary()}" + ) + + +@pytest.mark.integration +@pytest.mark.docker +async def test_server_limit_graceful(icap_service, load_metrics: LoadTestMetrics): + """Test behavior when exceeding server connection limit (MaxServers=10). + + Opens more connections than the server allows and verifies + graceful error handling rather than crashes. + """ + content = b"Connection limit test" + num_connections = 20 # More than MaxServers=10 + + async def hold_connection(conn_id: int, hold_time: float = 2.0) -> None: + """Open connection, do a scan, hold briefly.""" + start = time.perf_counter() + try: + async with AsyncIcapClient( + icap_service["host"], icap_service["port"], timeout=10 + ) as client: + response = await client.scan_bytes( + content, + service=icap_service["service"], + filename=f"limit_{conn_id}.txt", + ) + # Hold the connection open briefly + await asyncio.sleep(hold_time) + + if response.is_no_modification or response.is_success: + load_metrics.record_success((time.perf_counter() - start) * 1000) + else: + load_metrics.record_failure(f"Status: {response.status_code}") + except (IcapConnectionError, asyncio.TimeoutError, ConnectionRefusedError) as e: + # Expected when exceeding limits + load_metrics.record_failure(e) + except Exception as e: + load_metrics.record_failure(f"Unexpected: {type(e).__name__}: {e}") + + # Try to open all connections simultaneously + await asyncio.gather(*[hold_connection(i) for i in range(num_connections)]) + + # Some should succeed, some may fail - but no crashes + assert load_metrics.success_count > 0, "No connections succeeded" + # Verify we recorded all attempts (success + failure = total) + assert load_metrics.total_count == num_connections + + +@pytest.mark.integration +@pytest.mark.docker +def test_no_fd_leak(icap_service): + """Test that file descriptors are properly cleaned up after scans. + + Performs many scans and verifies FD count returns to baseline. + """ + initial_fds = get_open_fd_count() + if initial_fds == -1: + pytest.skip("Cannot measure FD count on this platform") + + content = b"FD leak test content" + num_scans = 50 + + # Perform many scans + for i in range(num_scans): + with IcapClient(icap_service["host"], icap_service["port"]) as client: + response = client.scan_bytes( + content, + service=icap_service["service"], + filename=f"fdtest_{i}.txt", + ) + assert response.is_no_modification or response.is_success + + # Check FD count returned to near baseline + # Allow small tolerance for pytest/logging/etc + final_fds = get_open_fd_count() + fd_growth = final_fds - initial_fds + + assert fd_growth < 10, ( + f"FD count grew by {fd_growth} after {num_scans} scans. " + f"Initial: {initial_fds}, Final: {final_fds}" + ) + + +@pytest.mark.integration +@pytest.mark.docker +@pytest.mark.slow +def test_no_memory_leak_sustained(icap_service, memory_tracker): + """Test memory stability over many sequential scans. + + Performs 100 sequential scans and verifies memory doesn't grow significantly. + """ + content = b"Memory leak test content " * 100 # ~2.5KB per scan + num_scans = 100 + + with memory_tracker() as stats: + with IcapClient(icap_service["host"], icap_service["port"]) as client: + for i in range(num_scans): + response = client.scan_bytes( + content, + service=icap_service["service"], + filename=f"memtest_{i}.txt", + ) + assert response.is_no_modification or response.is_success + + # Memory growth should be minimal for sequential scans + # Allow up to 10MB for buffers, protocol overhead + assert stats.growth_mb < 10, ( + f"Memory grew by {stats.growth_mb:.1f}MB over {num_scans} scans. " + f"Peak: {stats.peak_mb:.1f}MB" + ) diff --git a/tests/test_connection_robustness.py b/tests/test_connection_robustness.py new file mode 100644 index 0000000..5da5daa --- /dev/null +++ b/tests/test_connection_robustness.py @@ -0,0 +1,333 @@ +"""Connection robustness integration tests. + +These tests verify the ICAP client handles connection edge cases correctly, +including reconnection, persistence, and recovery from errors. + +All tests require Docker (c-icap + ClamAV) to be running. +""" + +from __future__ import annotations + +import os +import time + +import pytest + +from icap import AsyncIcapClient, IcapClient +from tests.conftest import wait_for_icap_service + +# TODO: These tests are flaky in CI due to the Docker-based ICAP server returning +# unexpected 307 redirects. The tests pass locally but fail intermittently in GitHub +# Actions. Investigation needed into the CI Docker environment configuration. +# See: https://github.com/CaptainDriftwood/python-icap/pull/42 +CI = os.environ.get("CI", "false").lower() == "true" + +# EICAR test string for virus detection +EICAR = b"X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*" + + +@pytest.mark.integration +@pytest.mark.docker +def test_multiple_sequential_requests(icap_service): + """Test 50 sequential scans on the same connection. + + Verifies connection persistence and reuse across many requests. + """ + content = b"Sequential request test content" + num_requests = 50 + + with IcapClient(icap_service["host"], icap_service["port"]) as client: + for i in range(num_requests): + response = client.scan_bytes( + content, + service=icap_service["service"], + filename=f"sequential_{i}.txt", + ) + assert response.is_no_modification or response.is_success, ( + f"Request {i} failed with status {response.status_code}" + ) + + # Connection should still be active + assert client.is_connected + + +@pytest.mark.integration +@pytest.mark.docker +@pytest.mark.skipif(CI, reason="Flaky in CI: receives 307 redirects from Docker ICAP server") +def test_connection_reuse_after_virus(icap_service): + """Test connection remains usable after virus detection. + + Scans EICAR (detected as virus), then clean content on same connection. + """ + clean_content = b"Clean content after virus scan" + + with IcapClient(icap_service["host"], icap_service["port"]) as client: + # First scan: EICAR virus + virus_response = client.scan_bytes( + EICAR, + service=icap_service["service"], + filename="eicar.com", + ) + # Should be detected (not 204 No Modification) + assert not virus_response.is_no_modification, "EICAR should be detected as virus" + + # Second scan: clean content on same connection + clean_response = client.scan_bytes( + clean_content, + service=icap_service["service"], + filename="clean.txt", + ) + assert clean_response.is_no_modification, "Clean content should pass after virus scan" + + # Connection should still be active + assert client.is_connected + + +@pytest.mark.integration +@pytest.mark.docker +def test_reconnect_after_disconnect(icap_service): + """Test manual disconnect and reconnect cycle.""" + content = b"Reconnect test content" + + client = IcapClient(icap_service["host"], icap_service["port"]) + + # First connection + client.connect() + assert client.is_connected + + response1 = client.scan_bytes( + content, + service=icap_service["service"], + filename="reconnect1.txt", + ) + assert response1.is_no_modification or response1.is_success + + # Disconnect + client.disconnect() + assert not client.is_connected + + # Reconnect + client.connect() + assert client.is_connected + + response2 = client.scan_bytes( + content, + service=icap_service["service"], + filename="reconnect2.txt", + ) + assert response2.is_no_modification or response2.is_success + + # Cleanup + client.disconnect() + + +@pytest.mark.integration +@pytest.mark.docker +@pytest.mark.slow +def test_reconnect_after_server_restart(icap_service, docker_controller): + """Test recovery after server restart. + + Performs scan, restarts Docker container, then scans again. + Requires new connection after restart. + """ + content = b"Server restart test content" + + # First scan + with IcapClient(icap_service["host"], icap_service["port"]) as client: + response1 = client.scan_bytes( + content, + service=icap_service["service"], + filename="before_restart.txt", + ) + assert response1.is_no_modification or response1.is_success + + # Restart the ICAP server container + docker_controller.restart() + + # Wait for service to be ready again + wait_for_icap_service( + icap_service["host"], + icap_service["port"], + icap_service["service"], + timeout=120, + ) + + # Second scan with new connection + with IcapClient(icap_service["host"], icap_service["port"]) as client: + response2 = client.scan_bytes( + content, + service=icap_service["service"], + filename="after_restart.txt", + ) + assert response2.is_no_modification or response2.is_success + + +@pytest.mark.integration +@pytest.mark.docker +@pytest.mark.slow +def test_idle_connection_30s(icap_service): + """Test connection remains usable after 30 seconds idle. + + Note: Server KeepAliveTimeout is 30s, so this tests the boundary. + """ + content = b"Idle connection test content" + + with IcapClient(icap_service["host"], icap_service["port"]) as client: + # First scan + response1 = client.scan_bytes( + content, + service=icap_service["service"], + filename="before_idle.txt", + ) + assert response1.is_no_modification or response1.is_success + + # Wait 30 seconds + time.sleep(30) + + # Try another scan - may need reconnect if server closed connection + try: + response2 = client.scan_bytes( + content, + service=icap_service["service"], + filename="after_idle.txt", + ) + assert response2.is_no_modification or response2.is_success + except Exception: + # If connection was dropped, reconnect should work + client.disconnect() + client.connect() + response2 = client.scan_bytes( + content, + service=icap_service["service"], + filename="after_idle_reconnect.txt", + ) + assert response2.is_no_modification or response2.is_success + + +@pytest.mark.integration +@pytest.mark.docker +def test_connection_state_consistency(icap_service): + """Test is_connected property accuracy through lifecycle.""" + client = IcapClient(icap_service["host"], icap_service["port"]) + + # Initially not connected + assert not client.is_connected + + # After connect + client.connect() + assert client.is_connected + + # After successful operation + response = client.options(icap_service["service"]) + assert response.is_success + assert client.is_connected + + # After disconnect + client.disconnect() + assert not client.is_connected + + # Reconnect via context manager + with client: + assert client.is_connected + + # After context manager exit + assert not client.is_connected + + +@pytest.mark.integration +@pytest.mark.docker +async def test_async_connection_persistence(icap_service): + """Test async client handles 20 sequential requests correctly.""" + content = b"Async persistence test content" + num_requests = 20 + + async with AsyncIcapClient(icap_service["host"], icap_service["port"]) as client: + for i in range(num_requests): + response = await client.scan_bytes( + content, + service=icap_service["service"], + filename=f"async_seq_{i}.txt", + ) + assert response.is_no_modification or response.is_success, ( + f"Async request {i} failed with status {response.status_code}" + ) + + # Connection should still be active + assert client.is_connected + + +@pytest.mark.integration +@pytest.mark.docker +async def test_async_reconnect_after_error(icap_service): + """Test async client recovery after connection issues.""" + content = b"Async recovery test content" + + # First connection + async with AsyncIcapClient(icap_service["host"], icap_service["port"]) as client: + response1 = await client.scan_bytes( + content, + service=icap_service["service"], + filename="async_before.txt", + ) + assert response1.is_no_modification or response1.is_success + + # Second connection (simulates recovery after context exit) + async with AsyncIcapClient(icap_service["host"], icap_service["port"]) as client: + response2 = await client.scan_bytes( + content, + service=icap_service["service"], + filename="async_after.txt", + ) + assert response2.is_no_modification or response2.is_success + + +@pytest.mark.integration +@pytest.mark.docker +def test_options_then_scan_same_connection(icap_service): + """Test OPTIONS followed by scan on same connection.""" + content = b"Options then scan test" + + with IcapClient(icap_service["host"], icap_service["port"]) as client: + # First: OPTIONS request + options_response = client.options(icap_service["service"]) + assert options_response.is_success + assert "Methods" in options_response.headers + + # Then: scan request + scan_response = client.scan_bytes( + content, + service=icap_service["service"], + filename="after_options.txt", + ) + assert scan_response.is_no_modification or scan_response.is_success + + +@pytest.mark.integration +@pytest.mark.docker +@pytest.mark.skipif(CI, reason="Flaky in CI: receives 307 redirects from Docker ICAP server") +def test_alternating_clean_and_virus(icap_service): + """Test alternating between clean content and virus detection.""" + clean_content = b"This is clean content" + + with IcapClient(icap_service["host"], icap_service["port"]) as client: + for i in range(5): + # Clean scan + clean_response = client.scan_bytes( + clean_content, + service=icap_service["service"], + filename=f"clean_{i}.txt", + ) + assert clean_response.is_no_modification, f"Clean scan {i} should pass" + + # Virus scan + virus_response = client.scan_bytes( + EICAR, + service=icap_service["service"], + filename=f"virus_{i}.com", + ) + assert not virus_response.is_no_modification, f"Virus {i} should be detected" + + # Connection should still be healthy + assert client.is_connected + final_response = client.options(icap_service["service"]) + assert final_response.is_success diff --git a/tests/test_error_handling.py b/tests/test_error_handling.py index bf3a9aa..43fd2c7 100644 --- a/tests/test_error_handling.py +++ b/tests/test_error_handling.py @@ -1024,3 +1024,232 @@ async def test_async_scan_bytes_auto_connects(mocker): response = await client.scan_bytes(b"test content") assert response.is_no_modification assert client.is_connected + + +# Additional error handling tests for coverage gaps + + +def test_receive_response_body_in_multiple_reads(): + """Test receiving response body that arrives in multiple recv() calls.""" + client = IcapClient("localhost", 1344) + + mock_socket = MagicMock() + # Body content "Hello World!" (12 bytes) arrives in 3 chunks + mock_socket.recv.side_effect = [ + b"ICAP/1.0 200 OK\r\nContent-Length: 12\r\n\r\nHell", # Initial + partial body + b"o Wo", # Middle chunk + b"rld!", # Final chunk + ] + + client._socket = mock_socket + client._connected = True + + response = client._receive_response() + + assert response.status_code == 200 + assert response.body == b"Hello World!" + # Verify multiple recv calls were made + assert mock_socket.recv.call_count == 3 + + +def test_receive_response_timeout_during_recv(): + """Test that socket.timeout during recv() raises IcapTimeoutError.""" + import socket + + from icap.exception import IcapTimeoutError + + client = IcapClient("localhost", 1344) + + mock_socket = MagicMock() + # First recv returns headers with Content-Length, second times out + mock_socket.recv.side_effect = [ + b"ICAP/1.0 200 OK\r\nContent-Length: 100\r\n\r\npartial", + socket.timeout("recv timed out"), + ] + + client._socket = mock_socket + client._connected = True + + with pytest.raises(IcapTimeoutError) as exc_info: + client._receive_response() + + assert "timed out" in str(exc_info.value) + + +def test_receive_response_oserror_during_recv(): + """Test that OSError during recv() raises IcapConnectionError and marks disconnected.""" + from icap.exception import IcapConnectionError + + client = IcapClient("localhost", 1344) + + mock_socket = MagicMock() + # First recv returns headers, second raises OSError + mock_socket.recv.side_effect = [ + b"ICAP/1.0 200 OK\r\nContent-Length: 100\r\n\r\npartial", + OSError("Connection reset by peer"), + ] + + client._socket = mock_socket + client._connected = True + + with pytest.raises(IcapConnectionError) as exc_info: + client._receive_response() + + assert "Connection error" in str(exc_info.value) + assert not client.is_connected # Should mark as disconnected + + +def test_receive_response_body_exact_size(): + """Test receiving body that exactly matches Content-Length in initial read.""" + client = IcapClient("localhost", 1344) + + mock_socket = MagicMock() + # Entire response including full body in single read + mock_socket.recv.return_value = b"ICAP/1.0 200 OK\r\nContent-Length: 5\r\n\r\nHello" + + client._socket = mock_socket + client._connected = True + + response = client._receive_response() + + assert response.status_code == 200 + assert response.body == b"Hello" + + +def test_send_and_receive_timeout_during_send(): + """Test that socket.timeout during sendall() raises IcapTimeoutError.""" + import socket + + from icap.exception import IcapTimeoutError + + client = IcapClient("localhost", 1344) + + mock_socket = MagicMock() + mock_socket.sendall.side_effect = socket.timeout("send timed out") + + client._socket = mock_socket + client._connected = True + + with pytest.raises(IcapTimeoutError) as exc_info: + client._send_and_receive(b"test request") + + assert "timed out" in str(exc_info.value) + + +def test_send_and_receive_oserror_during_send(): + """Test that OSError during sendall() raises IcapConnectionError.""" + from icap.exception import IcapConnectionError + + client = IcapClient("localhost", 1344) + + mock_socket = MagicMock() + mock_socket.sendall.side_effect = OSError("Broken pipe") + + client._socket = mock_socket + client._connected = True + + with pytest.raises(IcapConnectionError) as exc_info: + client._send_and_receive(b"test request") + + assert "Connection error" in str(exc_info.value) + assert not client.is_connected + + +async def test_async_receive_response_body_in_multiple_reads(mocker): + """Test async receiving response body that arrives in multiple read() calls.""" + from icap import AsyncIcapClient + + client = AsyncIcapClient("localhost", 1344) + + mock_writer = mocker.MagicMock() + mock_writer.write = mocker.MagicMock() + mock_writer.drain = mocker.AsyncMock() + + # Track call count for the read mock + read_results = [ + b"ICAP/1.0 200 OK\r\nContent-Length: 12\r\n\r\nHell", # Initial + partial + b"o Wo", # Middle + b"rld!", # Final + ] + read_index = {"i": 0} + + async def mock_read(*args): + idx = read_index["i"] + read_index["i"] += 1 + if idx < len(read_results): + return read_results[idx] + return b"" + + mock_reader = mocker.MagicMock() + mock_reader.read = mock_read + + mocker.patch( + "asyncio.open_connection", + return_value=(mock_reader, mock_writer), + ) + + # Bypass wait_for to avoid timeout complexity in tests + async def passthrough_wait_for(coro, timeout): + return await coro + + mocker.patch("asyncio.wait_for", passthrough_wait_for) + + await client.connect() + response = await client._receive_response() + + assert response.status_code == 200 + assert response.body == b"Hello World!" + assert read_index["i"] == 3 + + +async def test_async_receive_response_oserror_during_read(mocker): + """Test that OSError during async read() raises IcapConnectionError via _send_and_receive.""" + from icap import AsyncIcapClient + from icap.exception import IcapConnectionError + + client = AsyncIcapClient("localhost", 1344) + + mock_writer = mocker.MagicMock() + mock_writer.write = mocker.MagicMock() + mock_writer.drain = mocker.AsyncMock() + + # First read returns headers, second raises OSError + read_results = [ + b"ICAP/1.0 200 OK\r\nContent-Length: 100\r\n\r\npartial", + OSError("Connection reset"), + ] + read_index = {"i": 0} + + async def mock_read(*args): + idx = read_index["i"] + read_index["i"] += 1 + if idx < len(read_results): + result = read_results[idx] + if isinstance(result, Exception): + raise result + return result + return b"" + + mock_reader = mocker.MagicMock() + mock_reader.read = mock_read + + mocker.patch( + "asyncio.open_connection", + return_value=(mock_reader, mock_writer), + ) + + # Bypass wait_for + async def passthrough_wait_for(coro, timeout): + return await coro + + mocker.patch("asyncio.wait_for", passthrough_wait_for) + + await client.connect() + + # Call _send_and_receive which handles OSError from _receive_response + with pytest.raises(IcapConnectionError) as exc_info: + await client._send_and_receive(b"test request") + + assert "Connection reset" in str(exc_info.value) + # Verify client is marked as disconnected + assert not client.is_connected diff --git a/tests/test_fuzzing.py b/tests/test_fuzzing.py new file mode 100644 index 0000000..fc31e5b --- /dev/null +++ b/tests/test_fuzzing.py @@ -0,0 +1,301 @@ +""" +Property-based fuzzing tests using Hypothesis. + +These tests use random input generation to find edge cases in parsing +and validation logic that might not be caught by example-based tests. +""" + +import pytest +from hypothesis import assume, given, settings +from hypothesis import strategies as st + +from icap import IcapResponse +from icap._protocol import ( + parse_chunk_size, + parse_response_headers, + validate_body_size, + validate_content_length, +) +from icap.exception import IcapProtocolError +from icap.response import EncapsulatedParts + +# ============================================================================= +# IcapResponse.parse() fuzzing +# ============================================================================= + + +@given(st.binary()) +@settings(max_examples=500) +def test_fuzz_response_parse_arbitrary_bytes(data: bytes): + """IcapResponse.parse should never crash on arbitrary bytes. + + It should either: + - Return a valid IcapResponse + - Raise ValueError for malformed input + """ + try: + response = IcapResponse.parse(data) + # If parsing succeeds, verify basic invariants + assert isinstance(response.status_code, int) + assert isinstance(response.status_message, str) + assert isinstance(response.body, bytes) + except ValueError: + pass # Expected for malformed input + + +@given( + status_code=st.integers(min_value=100, max_value=599), + status_message=st.text(min_size=1, max_size=50).filter( + lambda s: "\r" not in s and "\n" not in s + ), +) +@settings(max_examples=200) +def test_fuzz_response_parse_valid_status_line(status_code: int, status_message: str): + """Valid status lines should always parse successfully.""" + data = f"ICAP/1.0 {status_code} {status_message}\r\n\r\n".encode() + response = IcapResponse.parse(data) + + assert response.status_code == status_code + assert response.status_message == status_message + + +@given(status_code=st.integers().filter(lambda x: x < 100 or x > 599)) +@settings(max_examples=100) +def test_fuzz_response_parse_invalid_status_code(status_code: int): + """Status codes outside 100-599 should raise ValueError.""" + data = f"ICAP/1.0 {status_code} Test\r\n\r\n".encode() + + with pytest.raises(ValueError, match="Invalid ICAP status code"): + IcapResponse.parse(data) + + +@given( + header_name=st.text( + alphabet=st.characters(blacklist_categories=("Cc", "Cs"), blacklist_characters=":\r\n"), + min_size=1, + max_size=30, + ), + header_value=st.text(max_size=100).filter(lambda s: "\r" not in s and "\n" not in s), +) +@settings(max_examples=200) +def test_fuzz_response_parse_headers(header_name: str, header_value: str): + """Headers should be parsed correctly regardless of content.""" + # Skip header names that would be stripped to empty + assume(header_name.strip()) + + data = f"ICAP/1.0 200 OK\r\n{header_name}: {header_value}\r\n\r\n".encode() + + try: + response = IcapResponse.parse(data) + # Header should be accessible (case-insensitive) + # Note: header name gets stripped, so compare stripped versions + stripped_name = header_name.strip() + assert stripped_name in response.headers or stripped_name.lower() in response.headers + except ValueError: + pass # Some header names may be invalid + + +# ============================================================================= +# parse_chunk_size() fuzzing +# ============================================================================= + + +@given(chunk_size=st.integers(min_value=0, max_value=100_000_000)) +@settings(max_examples=300) +def test_fuzz_chunk_size_valid_hex(chunk_size: int): + """Valid hex chunk sizes should parse correctly.""" + size_line = f"{chunk_size:X}".encode() + max_size = 100_000_001 # Larger than any generated value + + result = parse_chunk_size(size_line, max_size) + assert result == chunk_size + + +@given(chunk_size=st.integers(min_value=0, max_value=1000)) +@settings(max_examples=100) +def test_fuzz_chunk_size_with_extension(chunk_size: int): + """Chunk sizes with extensions should parse correctly.""" + size_line = f"{chunk_size:X}; ieof".encode() + max_size = 10_000 + + result = parse_chunk_size(size_line, max_size) + assert result == chunk_size + + +@given(chunk_size=st.integers(min_value=1001, max_value=10000)) +@settings(max_examples=100) +def test_fuzz_chunk_size_exceeds_max(chunk_size: int): + """Chunk sizes exceeding max should raise IcapProtocolError.""" + size_line = f"{chunk_size:X}".encode() + max_size = 1000 + + with pytest.raises(IcapProtocolError, match="exceeds maximum"): + parse_chunk_size(size_line, max_size) + + +@given(data=st.binary()) +@settings(max_examples=300) +def test_fuzz_chunk_size_arbitrary_bytes(data: bytes): + """parse_chunk_size should handle arbitrary bytes without crashing. + + It should either return a valid size or raise IcapProtocolError. + """ + max_size = 100_000_000 + + try: + result = parse_chunk_size(data, max_size) + assert isinstance(result, int) + assert result >= 0 + assert result <= max_size + except IcapProtocolError: + pass # Expected for invalid input + + +# ============================================================================= +# parse_response_headers() fuzzing +# ============================================================================= + + +@given(content_length=st.integers(min_value=0, max_value=1_000_000_000)) +@settings(max_examples=200) +def test_fuzz_response_headers_valid_content_length(content_length: int): + """Valid Content-Length values should parse correctly.""" + headers_str = f"ICAP/1.0 200 OK\r\nContent-Length: {content_length}" + + result = parse_response_headers(headers_str) + assert result.content_length == content_length + assert result.is_chunked is False + + +@given(content_length=st.integers(max_value=-1)) +@settings(max_examples=100) +def test_fuzz_response_headers_negative_content_length(content_length: int): + """Negative Content-Length should raise IcapProtocolError.""" + headers_str = f"ICAP/1.0 200 OK\r\nContent-Length: {content_length}" + + with pytest.raises(IcapProtocolError, match="must be non-negative"): + parse_response_headers(headers_str) + + +def test_fuzz_response_headers_chunked(): + """Transfer-Encoding: chunked should be detected.""" + headers_str = "ICAP/1.0 200 OK\r\nTransfer-Encoding: chunked" + + result = parse_response_headers(headers_str) + assert result.is_chunked is True + assert result.content_length is None + + +@given(header_value=st.text(max_size=50).filter(lambda s: "\r" not in s and "\n" not in s)) +@settings(max_examples=100) +def test_fuzz_response_headers_invalid_content_length(header_value: str): + """Non-numeric Content-Length should raise IcapProtocolError.""" + # Filter out values that could be valid integers + assume(not header_value.strip().lstrip("-").isdigit()) + assume(header_value.strip()) # Must have some content + + headers_str = f"ICAP/1.0 200 OK\r\nContent-Length: {header_value}" + + with pytest.raises(IcapProtocolError, match="Invalid Content-Length"): + parse_response_headers(headers_str) + + +# ============================================================================= +# EncapsulatedParts.parse() fuzzing +# ============================================================================= + + +@given(offset=st.integers(min_value=0, max_value=1_000_000)) +@settings(max_examples=200) +def test_fuzz_encapsulated_valid_offsets(offset: int): + """Valid offsets should parse correctly.""" + header_value = f"res-hdr=0, res-body={offset}" + + result = EncapsulatedParts.parse(header_value) + assert result.res_hdr == 0 + assert result.res_body == offset + + +@given( + req_hdr=st.integers(min_value=0, max_value=1000), + res_hdr=st.integers(min_value=0, max_value=1000), + res_body=st.integers(min_value=0, max_value=10000), +) +@settings(max_examples=200) +def test_fuzz_encapsulated_multiple_fields(req_hdr: int, res_hdr: int, res_body: int): + """Multiple fields should all be parsed.""" + header_value = f"req-hdr={req_hdr}, res-hdr={res_hdr}, res-body={res_body}" + + result = EncapsulatedParts.parse(header_value) + assert result.req_hdr == req_hdr + assert result.res_hdr == res_hdr + assert result.res_body == res_body + + +@given(offset=st.integers(max_value=-1)) +@settings(max_examples=100) +def test_fuzz_encapsulated_negative_offset(offset: int): + """Negative offsets should be silently ignored (treated as invalid).""" + header_value = f"res-body={offset}" + + result = EncapsulatedParts.parse(header_value) + # Negative offsets are ignored, field remains None + assert result.res_body is None + + +@given(header_value=st.text(max_size=200)) +@settings(max_examples=300) +def test_fuzz_encapsulated_arbitrary_text(header_value: str): + """EncapsulatedParts.parse should handle arbitrary text without crashing. + + It should either parse valid fields or silently ignore invalid segments. + """ + result = EncapsulatedParts.parse(header_value) + + # Result should always be a valid EncapsulatedParts instance + assert isinstance(result, EncapsulatedParts) + # All fields should be None or valid integers + for field in [ + result.req_hdr, + result.req_body, + result.res_hdr, + result.res_body, + result.null_body, + result.opt_body, + ]: + assert field is None or isinstance(field, int) + + +# ============================================================================= +# validate_body_size() and validate_content_length() fuzzing +# ============================================================================= + + +@given( + current_size=st.integers(min_value=0, max_value=1_000_000), + max_size=st.integers(min_value=1, max_value=1_000_000), +) +@settings(max_examples=200) +def test_fuzz_validate_body_size(current_size: int, max_size: int): + """validate_body_size should raise only when size exceeds max.""" + if current_size > max_size: + with pytest.raises(IcapProtocolError, match="exceeds maximum"): + validate_body_size(current_size, max_size) + else: + # Should not raise + validate_body_size(current_size, max_size) + + +@given( + content_length=st.integers(min_value=0, max_value=1_000_000), + max_size=st.integers(min_value=1, max_value=1_000_000), +) +@settings(max_examples=200) +def test_fuzz_validate_content_length(content_length: int, max_size: int): + """validate_content_length should raise only when length exceeds max.""" + if content_length > max_size: + with pytest.raises(IcapProtocolError, match="exceeds maximum"): + validate_content_length(content_length, max_size) + else: + # Should not raise + validate_content_length(content_length, max_size) diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 0000000..6fd189e --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,186 @@ +"""Tests for test helper utilities.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from tests.helpers import ( + KB, + MB, + LoadTestMetrics, + MemoryStats, + generate_random_bytes, + generate_random_file, + get_open_fd_count, + track_memory, +) + + +def test_generate_random_bytes_size(): + """Test that generate_random_bytes returns correct size.""" + data = generate_random_bytes(1000) + assert len(data) == 1000 + + +def test_generate_random_bytes_is_random(): + """Test that generate_random_bytes returns different data each call.""" + data1 = generate_random_bytes(100) + data2 = generate_random_bytes(100) + assert data1 != data2 + + +def test_generate_random_file(tmp_path: Path): + """Test that generate_random_file creates file of correct size.""" + file_path = tmp_path / "test.bin" + result = generate_random_file(1 * KB, file_path) + + assert result == file_path + assert file_path.exists() + assert file_path.stat().st_size == 1 * KB + + +def test_generate_random_file_large(tmp_path: Path): + """Test generating a larger file (1MB).""" + file_path = tmp_path / "test_1mb.bin" + generate_random_file(1 * MB, file_path) + + assert file_path.exists() + assert file_path.stat().st_size == 1 * MB + + +def test_memory_stats_growth(): + """Test MemoryStats growth calculation.""" + stats = MemoryStats(peak_mb=50.0, start_mb=10.0, end_mb=30.0) + assert stats.growth_mb == 20.0 + + +def test_track_memory_captures_peak(): + """Test that track_memory captures peak memory usage.""" + with track_memory() as stats: + # Allocate some memory + data = [b"x" * (1 * MB) for _ in range(5)] + # Keep reference to prevent GC + assert len(data) == 5 + + # Should have captured some memory usage + assert stats.peak_mb > 0 + assert stats.start_mb >= 0 + assert stats.end_mb >= 0 + + +def test_load_metrics_success(): + """Test LoadTestMetrics success tracking.""" + metrics = LoadTestMetrics() + metrics.record_success(10.0) + metrics.record_success(20.0) + metrics.record_success(30.0) + + assert metrics.success_count == 3 + assert metrics.failure_count == 0 + assert metrics.total_count == 3 + assert metrics.success_rate == 1.0 + assert metrics.success_rate_percent == 100.0 + assert metrics.avg_latency_ms == 20.0 + + +def test_load_metrics_failure(): + """Test LoadTestMetrics failure tracking.""" + metrics = LoadTestMetrics() + metrics.record_success(10.0) + metrics.record_failure("Connection error") + metrics.record_failure(ValueError("Bad value"), latency_ms=5.0) + + assert metrics.success_count == 1 + assert metrics.failure_count == 2 + assert metrics.success_rate == pytest.approx(1 / 3) + assert len(metrics.errors) == 2 + assert "Connection error" in metrics.errors + assert "Bad value" in metrics.errors + + +def test_load_metrics_percentiles(): + """Test LoadTestMetrics percentile calculations.""" + metrics = LoadTestMetrics() + # Add 100 latencies from 1 to 100 + for i in range(1, 101): + metrics.record_success(float(i)) + + assert metrics.min_latency_ms == 1.0 + assert metrics.max_latency_ms == 100.0 + # Percentile implementation uses floor index, so p50 of 1-100 is index 50 = value 51 + assert 50 <= metrics.p50_latency_ms <= 51 + assert 95 <= metrics.p95_latency_ms <= 96 + assert 99 <= metrics.p99_latency_ms <= 100 + + +def test_load_metrics_empty(): + """Test LoadTestMetrics with no data.""" + metrics = LoadTestMetrics() + + assert metrics.total_count == 0 + assert metrics.success_rate == 0.0 + assert metrics.avg_latency_ms == 0.0 + assert metrics.p99_latency_ms == 0.0 + + +def test_load_metrics_summary(): + """Test LoadTestMetrics summary output.""" + metrics = LoadTestMetrics() + metrics.record_success(10.0) + metrics.record_success(20.0) + metrics.record_failure("Error") + + summary = metrics.summary() + + assert "Total: 3 operations" in summary + assert "Success: 2" in summary + assert "Failures: 1" in summary + assert "Latency avg:" in summary + + +def test_get_open_fd_count(): + """Test that get_open_fd_count returns a reasonable value.""" + count = get_open_fd_count() + # Should return a positive number or -1 on unsupported platforms + assert count == -1 or count > 0 + + +# ============================================================================= +# Fixture Tests +# ============================================================================= + + +def test_large_file_10mb_fixture(large_file_10mb: Path): + """Test that large_file_10mb fixture creates correct file.""" + assert large_file_10mb.exists() + assert large_file_10mb.stat().st_size == 10 * MB + + +def test_large_file_factory_fixture(large_file_factory): + """Test that large_file_factory creates files of specified size.""" + file_5mb = large_file_factory(5 * MB) + assert file_5mb.exists() + assert file_5mb.stat().st_size == 5 * MB + + file_2mb = large_file_factory(2 * MB, name="custom.bin") + assert file_2mb.exists() + assert file_2mb.name == "custom.bin" + assert file_2mb.stat().st_size == 2 * MB + + +def test_memory_tracker_fixture(memory_tracker): + """Test that memory_tracker fixture provides track_memory.""" + with memory_tracker() as stats: + data = b"x" * (1 * MB) + assert len(data) == 1 * MB + + assert stats.peak_mb > 0 + + +def test_load_metrics_fixture(load_metrics: LoadTestMetrics): + """Test that load_metrics fixture provides fresh instance.""" + assert load_metrics.total_count == 0 + load_metrics.record_success(10.0) + assert load_metrics.success_count == 1 diff --git a/tests/test_icap.py b/tests/test_icap.py index cc5c952..af726bf 100644 --- a/tests/test_icap.py +++ b/tests/test_icap.py @@ -325,3 +325,45 @@ def test_response_parse_with_empty_headers(): assert response.status_code == 200 assert response.headers == {} assert response.body == b"body content" + + +def test_response_parse_header_without_colon(): + """Test parsing response with a malformed header line (no colon).""" + # Header line without colon should be skipped + raw = b"ICAP/1.0 200 OK\r\nServer: Test\r\nMalformedLine\r\nAnother: Valid\r\n\r\n" + response = IcapResponse.parse(raw) + + assert response.status_code == 200 + assert response.headers["Server"] == "Test" + assert response.headers["Another"] == "Valid" + # Malformed line without colon should not be in headers + assert "MalformedLine" not in response.headers + + +def test_response_parse_header_with_colon_in_value(): + """Test parsing header where value contains colons.""" + raw = b"ICAP/1.0 200 OK\r\nX-Info: value:with:colons\r\n\r\n" + response = IcapResponse.parse(raw) + + assert response.headers["X-Info"] == "value:with:colons" + + +# AsyncIcapClient property tests + + +def test_async_client_properties(): + """Test AsyncIcapClient property accessors.""" + client = AsyncIcapClient("example.com", port=1345, timeout=30.0) + + assert client.host == "example.com" + assert client.port == 1345 + assert not client.is_connected + + +def test_async_client_default_values(): + """Test AsyncIcapClient default property values.""" + client = AsyncIcapClient("localhost") + + assert client.host == "localhost" + assert client.port == 1344 # Default ICAP port + assert client._ssl_context is None diff --git a/tests/test_large_files.py b/tests/test_large_files.py new file mode 100644 index 0000000..f411722 --- /dev/null +++ b/tests/test_large_files.py @@ -0,0 +1,248 @@ +"""Large file handling integration tests. + +These tests verify that the ICAP client correctly handles large files +without memory issues and with proper streaming behavior. + +All tests require Docker (c-icap + ClamAV) to be running. +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +import pytest + +from icap import AsyncIcapClient, IcapClient +from tests.helpers import KB, MB + + +@pytest.mark.integration +@pytest.mark.docker +def test_scan_10mb_file(icap_service, large_file_10mb: Path): + """Test scanning a 10MB file completes successfully.""" + with IcapClient(icap_service["host"], icap_service["port"]) as client: + response = client.scan_file(large_file_10mb, service=icap_service["service"]) + + assert response.is_success or response.is_no_modification + # Clean random data should return 204 No Modification + assert response.status_code == 204 + + +@pytest.mark.integration +@pytest.mark.docker +@pytest.mark.slow +def test_scan_100mb_file(icap_service, large_file_100mb: Path): + """Test scanning a 100MB file completes without OOM.""" + with IcapClient(icap_service["host"], icap_service["port"]) as client: + response = client.scan_file(large_file_100mb, service=icap_service["service"]) + + assert response.is_success or response.is_no_modification + assert response.status_code == 204 + + +@pytest.mark.integration +@pytest.mark.docker +@pytest.mark.slow +def test_stream_large_file_memory_stable(icap_service, large_file_100mb: Path, memory_tracker): + """Test that streaming a 100MB file doesn't consume proportional memory. + + When using scan_stream with chunked transfer, memory usage should stay + well below the file size since data is streamed, not loaded entirely. + """ + with memory_tracker() as stats: + with IcapClient(icap_service["host"], icap_service["port"]) as client: + with open(large_file_100mb, "rb") as f: + response = client.scan_stream( + f, + service=icap_service["service"], + filename="test_100mb.bin", + chunk_size=64 * KB, # Stream in 64KB chunks + ) + + assert response.is_success or response.is_no_modification + + # Memory growth should be much less than 100MB + # Allow up to 50MB for buffers, protocol overhead, etc. + assert stats.growth_mb < 50, ( + f"Memory grew by {stats.growth_mb:.1f}MB while streaming 100MB file. " + f"Expected <50MB growth for streaming." + ) + + +@pytest.mark.integration +@pytest.mark.docker +def test_chunked_stream_512b_chunks(icap_service, large_file_10mb: Path): + """Test streaming with very small (512 byte) chunks.""" + with IcapClient(icap_service["host"], icap_service["port"]) as client: + with open(large_file_10mb, "rb") as f: + response = client.scan_stream( + f, + service=icap_service["service"], + filename="test_small_chunks.bin", + chunk_size=512, + ) + + assert response.is_success or response.is_no_modification + assert response.status_code == 204 + + +@pytest.mark.integration +@pytest.mark.docker +def test_chunked_stream_64kb_chunks(icap_service, large_file_10mb: Path): + """Test streaming with medium (64KB) chunks.""" + with IcapClient(icap_service["host"], icap_service["port"]) as client: + with open(large_file_10mb, "rb") as f: + response = client.scan_stream( + f, + service=icap_service["service"], + filename="test_64kb_chunks.bin", + chunk_size=64 * KB, + ) + + assert response.is_success or response.is_no_modification + assert response.status_code == 204 + + +@pytest.mark.integration +@pytest.mark.docker +def test_chunked_stream_1mb_chunks(icap_service, large_file_10mb: Path): + """Test streaming with large (1MB) chunks.""" + with IcapClient(icap_service["host"], icap_service["port"]) as client: + with open(large_file_10mb, "rb") as f: + response = client.scan_stream( + f, + service=icap_service["service"], + filename="test_1mb_chunks.bin", + chunk_size=1 * MB, + ) + + assert response.is_success or response.is_no_modification + assert response.status_code == 204 + + +@pytest.mark.integration +@pytest.mark.docker +@pytest.mark.slow +def test_large_file_with_preview(icap_service, large_file_100mb: Path): + """Test scanning a large file with preview mode enabled. + + First queries OPTIONS to get preview size, then uses respmod with preview. + Note: scan_file() doesn't support preview, so we use the lower-level respmod(). + """ + with IcapClient(icap_service["host"], icap_service["port"]) as client: + # Get preview size from server + options_response = client.options(icap_service["service"]) + assert options_response.is_success + + preview_size_str = options_response.headers.get("Preview") + if preview_size_str is None: + pytest.skip("Server does not support preview mode") + + preview_size = int(preview_size_str) + + # Read file and build HTTP response for respmod + content = large_file_100mb.read_bytes() + http_request = b"GET /test HTTP/1.1\r\nHost: test\r\n\r\n" + http_response = ( + b"HTTP/1.1 200 OK\r\n" + b"Content-Type: application/octet-stream\r\n" + b"Content-Length: " + str(len(content)).encode() + b"\r\n" + b"\r\n" + ) + content + + # Scan with preview using respmod + response = client.respmod( + icap_service["service"], + http_request, + http_response, + preview=preview_size, + ) + + assert response.is_success or response.is_no_modification + assert response.status_code == 204 + + +@pytest.mark.integration +@pytest.mark.docker +async def test_async_large_file_scan(icap_service, large_file_10mb: Path): + """Test async scanning of a 10MB file.""" + async with AsyncIcapClient(icap_service["host"], icap_service["port"]) as client: + response = await client.scan_file(large_file_10mb, service=icap_service["service"]) + + assert response.is_success or response.is_no_modification + assert response.status_code == 204 + + +@pytest.mark.integration +@pytest.mark.docker +@pytest.mark.slow +async def test_async_concurrent_large_files(icap_service, large_file_factory): + """Test scanning 3 large files concurrently with async client. + + Creates 3 separate 10MB files and scans them concurrently. + """ + # Create 3 separate 10MB files + files = [large_file_factory(10 * MB, f"concurrent_{i}.bin") for i in range(3)] + + async def scan_file(file_path: Path) -> bool: + """Scan a single file and return success status.""" + async with AsyncIcapClient(icap_service["host"], icap_service["port"]) as client: + response = await client.scan_file(file_path, service=icap_service["service"]) + return response.is_no_modification or response.is_success + + # Scan all files concurrently + results = await asyncio.gather(*[scan_file(f) for f in files]) + + # All scans should succeed + assert all(results), f"Some scans failed: {results}" + + +@pytest.mark.integration +@pytest.mark.docker +async def test_async_stream_large_file(icap_service, large_file_10mb: Path): + """Test async streaming of a large file.""" + async with AsyncIcapClient(icap_service["host"], icap_service["port"]) as client: + with open(large_file_10mb, "rb") as f: + response = await client.scan_stream( + f, + service=icap_service["service"], + filename="async_stream_test.bin", + chunk_size=64 * KB, + ) + + assert response.is_success or response.is_no_modification + assert response.status_code == 204 + + +@pytest.mark.integration +@pytest.mark.docker +def test_scan_bytes_large_content(icap_service, large_file_factory): + """Test scan_bytes with large (5MB) content.""" + # Create a 5MB file and read its contents + file_path = large_file_factory(5 * MB) + content = file_path.read_bytes() + + with IcapClient(icap_service["host"], icap_service["port"]) as client: + response = client.scan_bytes( + content, + service=icap_service["service"], + filename="large_bytes.bin", + ) + + assert response.is_success or response.is_no_modification + assert response.status_code == 204 + + +@pytest.mark.integration +@pytest.mark.docker +def test_multiple_large_scans_same_connection(icap_service, large_file_factory): + """Test scanning multiple large files on the same connection.""" + # Create 5 files of 2MB each + files = [large_file_factory(2 * MB, f"multi_{i}.bin") for i in range(5)] + + with IcapClient(icap_service["host"], icap_service["port"]) as client: + for file_path in files: + response = client.scan_file(file_path, service=icap_service["service"]) + assert response.is_success or response.is_no_modification + assert response.status_code == 204 diff --git a/tests/test_pytest_plugin.py b/tests/test_pytest_plugin.py index 7c94c03..4f36e3b 100644 --- a/tests/test_pytest_plugin.py +++ b/tests/test_pytest_plugin.py @@ -1,8 +1,22 @@ """Tests for the icap.pytest_plugin using pytester.""" +import pytest + pytest_plugins = ["pytester"] +@pytest.fixture(autouse=True) +def setup_pytester_asyncio_config(pytester): + """Set asyncio config for all pytester subprocess tests to suppress warnings.""" + pytester.makeini( + """ + [pytest] + asyncio_mode = auto + asyncio_default_fixture_loop_scope = function + """ + ) + + def test_icap_marker_registered(pytester): """Verify the icap marker is properly registered.""" pytester.makepyfile( diff --git a/tests/test_response.py b/tests/test_response.py new file mode 100644 index 0000000..d9bcd58 --- /dev/null +++ b/tests/test_response.py @@ -0,0 +1,292 @@ +""" +Unit tests for IcapResponse and CaseInsensitiveDict. + +These tests verify correct parsing of ICAP responses, including +header handling per RFC 3507 and RFC 7230. +""" + +import pytest + +from icap import CaseInsensitiveDict, IcapResponse + +# ============================================================================= +# CaseInsensitiveDict tests +# ============================================================================= + + +def test_case_insensitive_dict_basic(): + """Basic case-insensitive key access.""" + headers = CaseInsensitiveDict() + headers["X-Virus-ID"] = "EICAR" + + assert headers["X-Virus-ID"] == "EICAR" + assert headers["x-virus-id"] == "EICAR" + assert headers["X-VIRUS-ID"] == "EICAR" + assert headers["x-Virus-Id"] == "EICAR" + + +def test_case_insensitive_dict_contains(): + """Case-insensitive 'in' operator.""" + headers = CaseInsensitiveDict() + headers["Content-Type"] = "text/plain" + + assert "Content-Type" in headers + assert "content-type" in headers + assert "CONTENT-TYPE" in headers + assert "X-Missing" not in headers + + +def test_case_insensitive_dict_get(): + """Case-insensitive get() method.""" + headers = CaseInsensitiveDict() + headers["X-Virus-ID"] = "EICAR" + + assert headers.get("X-Virus-ID") == "EICAR" + assert headers.get("x-virus-id") == "EICAR" + assert headers.get("X-Missing") is None + assert headers.get("X-Missing", "default") == "default" + + +def test_case_insensitive_dict_preserves_original_case(): + """Original case is preserved when iterating.""" + headers = CaseInsensitiveDict() + headers["X-Virus-ID"] = "EICAR" + headers["Content-Type"] = "text/plain" + + keys = list(headers.keys()) + assert "X-Virus-ID" in keys + assert "Content-Type" in keys + + +def test_case_insensitive_dict_overwrite(): + """Setting with different case overwrites value.""" + headers = CaseInsensitiveDict() + headers["X-Virus-ID"] = "first" + headers["x-virus-id"] = "second" + + assert len(headers) == 1 + assert headers["X-Virus-ID"] == "second" + + +def test_case_insensitive_dict_delete(): + """Case-insensitive deletion.""" + headers = CaseInsensitiveDict() + headers["X-Virus-ID"] = "EICAR" + + del headers["x-virus-id"] + assert "X-Virus-ID" not in headers + + +def test_case_insensitive_dict_init_with_data(): + """Initialize with existing dictionary.""" + headers = CaseInsensitiveDict({"X-Virus-ID": "EICAR", "Content-Type": "text/plain"}) + + assert headers["x-virus-id"] == "EICAR" + assert headers["content-type"] == "text/plain" + + +def test_case_insensitive_dict_repr(): + """String representation.""" + headers = CaseInsensitiveDict() + headers["X-Test"] = "value" + + assert "X-Test" in repr(headers) + assert "value" in repr(headers) + + +# ============================================================================= +# IcapResponse header parsing tests +# ============================================================================= + + +def test_parse_header_case_insensitive(): + """Parsed headers should be case-insensitive (RFC 3507).""" + data = b"ICAP/1.0 200 OK\r\nX-Virus-ID: EICAR\r\n\r\n" + response = IcapResponse.parse(data) + + assert response.headers["X-Virus-ID"] == "EICAR" + assert response.headers["x-virus-id"] == "EICAR" + assert response.headers["X-VIRUS-ID"] == "EICAR" + + +def test_parse_duplicate_headers_combined(): + """Duplicate headers should be combined with comma (RFC 7230 Section 3.2.2).""" + data = b"ICAP/1.0 200 OK\r\nX-Tag: first\r\nX-Tag: second\r\n\r\n" + response = IcapResponse.parse(data) + + assert response.headers["X-Tag"] == "first, second" + + +def test_parse_duplicate_headers_case_insensitive(): + """Duplicate headers with different case should still be combined.""" + data = b"ICAP/1.0 200 OK\r\nX-Tag: first\r\nx-tag: second\r\n\r\n" + response = IcapResponse.parse(data) + + assert response.headers["X-Tag"] == "first, second" + + +def test_parse_header_value_with_colon(): + """Header values containing colons should be preserved.""" + data = b"ICAP/1.0 200 OK\r\nX-Timestamp: 2024-01-15T12:30:00Z\r\n\r\n" + response = IcapResponse.parse(data) + + assert response.headers["X-Timestamp"] == "2024-01-15T12:30:00Z" + + +def test_parse_header_empty_value(): + """Headers with empty values should be handled.""" + data = b"ICAP/1.0 200 OK\r\nX-Empty:\r\n\r\n" + response = IcapResponse.parse(data) + + assert response.headers["X-Empty"] == "" + + +def test_parse_header_whitespace_preserved(): + """Internal whitespace in header values should be preserved.""" + data = b"ICAP/1.0 200 OK\r\nX-Message: hello world\r\n\r\n" + response = IcapResponse.parse(data) + + assert response.headers["X-Message"] == "hello world" + + +def test_parse_header_leading_trailing_whitespace_stripped(): + """Leading and trailing whitespace in values should be stripped.""" + data = b"ICAP/1.0 200 OK\r\nX-Padded: value \r\n\r\n" + response = IcapResponse.parse(data) + + assert response.headers["X-Padded"] == "value" + + +def test_parse_header_with_utf8(): + """UTF-8 characters in header values should be handled.""" + data = "ICAP/1.0 200 OK\r\nX-Info: café résumé\r\n\r\n".encode() + response = IcapResponse.parse(data) + + assert response.headers["X-Info"] == "café résumé" + + +def test_parse_header_with_equals(): + """Header values with equals signs should be preserved.""" + data = b"ICAP/1.0 200 OK\r\nX-Auth: token=abc123\r\n\r\n" + response = IcapResponse.parse(data) + + assert response.headers["X-Auth"] == "token=abc123" + + +def test_parse_header_with_semicolon(): + """Header values with semicolons should be preserved.""" + data = b"ICAP/1.0 200 OK\r\nX-Options: a=1; b=2; c=3\r\n\r\n" + response = IcapResponse.parse(data) + + assert response.headers["X-Options"] == "a=1; b=2; c=3" + + +def test_parse_multiple_colons_in_value(): + """Multiple colons in value should all be preserved.""" + data = b"ICAP/1.0 200 OK\r\nX-URL: http://example.com:8080/path\r\n\r\n" + response = IcapResponse.parse(data) + + assert response.headers["X-URL"] == "http://example.com:8080/path" + + +def test_response_init_converts_dict_to_case_insensitive(): + """IcapResponse.__init__ should convert regular dict to CaseInsensitiveDict.""" + response = IcapResponse(200, "OK", {"X-Virus-ID": "EICAR"}, b"") + + assert response.headers["x-virus-id"] == "EICAR" + assert isinstance(response.headers, CaseInsensitiveDict) + + +def test_response_init_accepts_case_insensitive_dict(): + """IcapResponse.__init__ should accept CaseInsensitiveDict directly.""" + headers = CaseInsensitiveDict({"X-Test": "value"}) + response = IcapResponse(200, "OK", headers, b"") + + assert response.headers is headers + + +# ============================================================================= +# IcapResponse basic parsing tests +# ============================================================================= + + +def test_parse_basic_response(): + """Basic response parsing.""" + data = b"ICAP/1.0 204 No Content\r\n\r\n" + response = IcapResponse.parse(data) + + assert response.status_code == 204 + assert response.status_message == "No Content" + assert response.is_no_modification + assert response.is_success + + +def test_parse_response_with_body(): + """Response with body content.""" + data = b"ICAP/1.0 200 OK\r\nContent-Length: 5\r\n\r\nhello" + response = IcapResponse.parse(data) + + assert response.status_code == 200 + assert response.body == b"hello" + + +def test_parse_invalid_status_line(): + """Invalid status line should raise ValueError.""" + data = b"INVALID\r\n\r\n" + + with pytest.raises(ValueError, match="Invalid ICAP status line"): + IcapResponse.parse(data) + + +def test_parse_status_code_too_low(): + """Status code below 100 should raise ValueError.""" + data = b"ICAP/1.0 99 Invalid\r\n\r\n" + + with pytest.raises(ValueError, match="Invalid ICAP status code"): + IcapResponse.parse(data) + + +def test_parse_status_code_too_high(): + """Status code above 599 should raise ValueError.""" + data = b"ICAP/1.0 600 Invalid\r\n\r\n" + + with pytest.raises(ValueError, match="Invalid ICAP status code"): + IcapResponse.parse(data) + + +def test_parse_status_code_negative(): + """Negative status code should raise ValueError.""" + data = b"ICAP/1.0 -1 Invalid\r\n\r\n" + + with pytest.raises(ValueError, match="Invalid ICAP status code"): + IcapResponse.parse(data) + + +# ============================================================================= +# ISTag property tests +# ============================================================================= + + +def test_istag_property_returns_value(): + """ISTag property returns header value when present.""" + data = b'ICAP/1.0 200 OK\r\nISTag: "AV-2026030101"\r\nMethods: RESPMOD\r\n\r\n' + response = IcapResponse.parse(data) + + assert response.istag == '"AV-2026030101"' + + +def test_istag_property_returns_none_when_missing(): + """ISTag property returns None when header not present.""" + data = b"ICAP/1.0 200 OK\r\nMethods: RESPMOD\r\n\r\n" + response = IcapResponse.parse(data) + + assert response.istag is None + + +def test_istag_property_case_insensitive(): + """ISTag header lookup is case-insensitive.""" + # Lowercase header name + data = b'ICAP/1.0 200 OK\r\nistag: "v1.0"\r\n\r\n' + response = IcapResponse.parse(data) + + assert response.istag == '"v1.0"' diff --git a/uv.lock b/uv.lock index 89b2760..83e28c2 100644 --- a/uv.lock +++ b/uv.lock @@ -2,7 +2,16 @@ version = 1 revision = 3 requires-python = ">=3.8" resolution-markers = [ - "python_full_version >= '3.10'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'emscripten'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.10.*'", "python_full_version >= '3.9.2' and python_full_version < '3.10'", "python_full_version >= '3.9' and python_full_version < '3.9.2'", "python_full_version < '3.9'", @@ -34,7 +43,16 @@ name = "attrs" version = "25.4.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.10'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'emscripten'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.10.*'", "python_full_version >= '3.9.2' and python_full_version < '3.10'", "python_full_version >= '3.9' and python_full_version < '3.9.2'", ] @@ -54,11 +72,11 @@ wheels = [ [[package]] name = "certifi" -version = "2025.11.12" +version = "2026.2.25" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] [[package]] @@ -411,104 +429,127 @@ toml = [ [[package]] name = "coverage" -version = "7.13.1" +version = "7.13.4" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.10'", -] -sdist = { url = "https://files.pythonhosted.org/packages/23/f9/e92df5e07f3fc8d4c7f9a0f146ef75446bf870351cd37b788cf5897f8079/coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd", size = 825862, upload-time = "2025-12-28T15:42:56.969Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/9a/3742e58fd04b233df95c012ee9f3dfe04708a5e1d32613bd2d47d4e1be0d/coverage-7.13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1fa280b3ad78eea5be86f94f461c04943d942697e0dac889fa18fff8f5f9147", size = 218633, upload-time = "2025-12-28T15:40:10.165Z" }, - { url = "https://files.pythonhosted.org/packages/7e/45/7e6bdc94d89cd7c8017ce735cf50478ddfe765d4fbf0c24d71d30ea33d7a/coverage-7.13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c3d8c679607220979434f494b139dfb00131ebf70bb406553d69c1ff01a5c33d", size = 219147, upload-time = "2025-12-28T15:40:12.069Z" }, - { url = "https://files.pythonhosted.org/packages/f7/38/0d6a258625fd7f10773fe94097dc16937a5f0e3e0cdf3adef67d3ac6baef/coverage-7.13.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:339dc63b3eba969067b00f41f15ad161bf2946613156fb131266d8debc8e44d0", size = 245894, upload-time = "2025-12-28T15:40:13.556Z" }, - { url = "https://files.pythonhosted.org/packages/27/58/409d15ea487986994cbd4d06376e9860e9b157cfbfd402b1236770ab8dd2/coverage-7.13.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db622b999ffe49cb891f2fff3b340cdc2f9797d01a0a202a0973ba2562501d90", size = 247721, upload-time = "2025-12-28T15:40:15.37Z" }, - { url = "https://files.pythonhosted.org/packages/da/bf/6e8056a83fd7a96c93341f1ffe10df636dd89f26d5e7b9ca511ce3bcf0df/coverage-7.13.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1443ba9acbb593fa7c1c29e011d7c9761545fe35e7652e85ce7f51a16f7e08d", size = 249585, upload-time = "2025-12-28T15:40:17.226Z" }, - { url = "https://files.pythonhosted.org/packages/f4/15/e1daff723f9f5959acb63cbe35b11203a9df77ee4b95b45fffd38b318390/coverage-7.13.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c832ec92c4499ac463186af72f9ed4d8daec15499b16f0a879b0d1c8e5cf4a3b", size = 246597, upload-time = "2025-12-28T15:40:19.028Z" }, - { url = "https://files.pythonhosted.org/packages/74/a6/1efd31c5433743a6ddbc9d37ac30c196bb07c7eab3d74fbb99b924c93174/coverage-7.13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:562ec27dfa3f311e0db1ba243ec6e5f6ab96b1edfcfc6cf86f28038bc4961ce6", size = 247626, upload-time = "2025-12-28T15:40:20.846Z" }, - { url = "https://files.pythonhosted.org/packages/6d/9f/1609267dd3e749f57fdd66ca6752567d1c13b58a20a809dc409b263d0b5f/coverage-7.13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4de84e71173d4dada2897e5a0e1b7877e5eefbfe0d6a44edee6ce31d9b8ec09e", size = 245629, upload-time = "2025-12-28T15:40:22.397Z" }, - { url = "https://files.pythonhosted.org/packages/e2/f6/6815a220d5ec2466383d7cc36131b9fa6ecbe95c50ec52a631ba733f306a/coverage-7.13.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a5a68357f686f8c4d527a2dc04f52e669c2fc1cbde38f6f7eb6a0e58cbd17cae", size = 245901, upload-time = "2025-12-28T15:40:23.836Z" }, - { url = "https://files.pythonhosted.org/packages/ac/58/40576554cd12e0872faf6d2c0eb3bc85f71d78427946ddd19ad65201e2c0/coverage-7.13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:77cc258aeb29a3417062758975521eae60af6f79e930d6993555eeac6a8eac29", size = 246505, upload-time = "2025-12-28T15:40:25.421Z" }, - { url = "https://files.pythonhosted.org/packages/3b/77/9233a90253fba576b0eee81707b5781d0e21d97478e5377b226c5b096c0f/coverage-7.13.1-cp310-cp310-win32.whl", hash = "sha256:bb4f8c3c9a9f34423dba193f241f617b08ffc63e27f67159f60ae6baf2dcfe0f", size = 221257, upload-time = "2025-12-28T15:40:27.217Z" }, - { url = "https://files.pythonhosted.org/packages/e0/43/e842ff30c1a0a623ec80db89befb84a3a7aad7bfe44a6ea77d5a3e61fedd/coverage-7.13.1-cp310-cp310-win_amd64.whl", hash = "sha256:c8e2706ceb622bc63bac98ebb10ef5da80ed70fbd8a7999a5076de3afaef0fb1", size = 222191, upload-time = "2025-12-28T15:40:28.916Z" }, - { url = "https://files.pythonhosted.org/packages/b4/9b/77baf488516e9ced25fc215a6f75d803493fc3f6a1a1227ac35697910c2a/coverage-7.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a55d509a1dc5a5b708b5dad3b5334e07a16ad4c2185e27b40e4dba796ab7f88", size = 218755, upload-time = "2025-12-28T15:40:30.812Z" }, - { url = "https://files.pythonhosted.org/packages/d7/cd/7ab01154e6eb79ee2fab76bf4d89e94c6648116557307ee4ebbb85e5c1bf/coverage-7.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d010d080c4888371033baab27e47c9df7d6fb28d0b7b7adf85a4a49be9298b3", size = 219257, upload-time = "2025-12-28T15:40:32.333Z" }, - { url = "https://files.pythonhosted.org/packages/01/d5/b11ef7863ffbbdb509da0023fad1e9eda1c0eaea61a6d2ea5b17d4ac706e/coverage-7.13.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d938b4a840fb1523b9dfbbb454f652967f18e197569c32266d4d13f37244c3d9", size = 249657, upload-time = "2025-12-28T15:40:34.1Z" }, - { url = "https://files.pythonhosted.org/packages/f7/7c/347280982982383621d29b8c544cf497ae07ac41e44b1ca4903024131f55/coverage-7.13.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf100a3288f9bb7f919b87eb84f87101e197535b9bd0e2c2b5b3179633324fee", size = 251581, upload-time = "2025-12-28T15:40:36.131Z" }, - { url = "https://files.pythonhosted.org/packages/82/f6/ebcfed11036ade4c0d75fa4453a6282bdd225bc073862766eec184a4c643/coverage-7.13.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef6688db9bf91ba111ae734ba6ef1a063304a881749726e0d3575f5c10a9facf", size = 253691, upload-time = "2025-12-28T15:40:37.626Z" }, - { url = "https://files.pythonhosted.org/packages/02/92/af8f5582787f5d1a8b130b2dcba785fa5e9a7a8e121a0bb2220a6fdbdb8a/coverage-7.13.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b609fc9cdbd1f02e51f67f51e5aee60a841ef58a68d00d5ee2c0faf357481a3", size = 249799, upload-time = "2025-12-28T15:40:39.47Z" }, - { url = "https://files.pythonhosted.org/packages/24/aa/0e39a2a3b16eebf7f193863323edbff38b6daba711abaaf807d4290cf61a/coverage-7.13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c43257717611ff5e9a1d79dce8e47566235ebda63328718d9b65dd640bc832ef", size = 251389, upload-time = "2025-12-28T15:40:40.954Z" }, - { url = "https://files.pythonhosted.org/packages/73/46/7f0c13111154dc5b978900c0ccee2e2ca239b910890e674a77f1363d483e/coverage-7.13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e09fbecc007f7b6afdfb3b07ce5bd9f8494b6856dd4f577d26c66c391b829851", size = 249450, upload-time = "2025-12-28T15:40:42.489Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ca/e80da6769e8b669ec3695598c58eef7ad98b0e26e66333996aee6316db23/coverage-7.13.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a03a4f3a19a189919c7055098790285cc5c5b0b3976f8d227aea39dbf9f8bfdb", size = 249170, upload-time = "2025-12-28T15:40:44.279Z" }, - { url = "https://files.pythonhosted.org/packages/af/18/9e29baabdec1a8644157f572541079b4658199cfd372a578f84228e860de/coverage-7.13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3820778ea1387c2b6a818caec01c63adc5b3750211af6447e8dcfb9b6f08dbba", size = 250081, upload-time = "2025-12-28T15:40:45.748Z" }, - { url = "https://files.pythonhosted.org/packages/00/f8/c3021625a71c3b2f516464d322e41636aea381018319050a8114105872ee/coverage-7.13.1-cp311-cp311-win32.whl", hash = "sha256:ff10896fa55167371960c5908150b434b71c876dfab97b69478f22c8b445ea19", size = 221281, upload-time = "2025-12-28T15:40:47.232Z" }, - { url = "https://files.pythonhosted.org/packages/27/56/c216625f453df6e0559ed666d246fcbaaa93f3aa99eaa5080cea1229aa3d/coverage-7.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:a998cc0aeeea4c6d5622a3754da5a493055d2d95186bad877b0a34ea6e6dbe0a", size = 222215, upload-time = "2025-12-28T15:40:49.19Z" }, - { url = "https://files.pythonhosted.org/packages/5c/9a/be342e76f6e531cae6406dc46af0d350586f24d9b67fdfa6daee02df71af/coverage-7.13.1-cp311-cp311-win_arm64.whl", hash = "sha256:fea07c1a39a22614acb762e3fbbb4011f65eedafcb2948feeef641ac78b4ee5c", size = 220886, upload-time = "2025-12-28T15:40:51.067Z" }, - { url = "https://files.pythonhosted.org/packages/ce/8a/87af46cccdfa78f53db747b09f5f9a21d5fc38d796834adac09b30a8ce74/coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3", size = 218927, upload-time = "2025-12-28T15:40:52.814Z" }, - { url = "https://files.pythonhosted.org/packages/82/a8/6e22fdc67242a4a5a153f9438d05944553121c8f4ba70cb072af4c41362e/coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e", size = 219288, upload-time = "2025-12-28T15:40:54.262Z" }, - { url = "https://files.pythonhosted.org/packages/d0/0a/853a76e03b0f7c4375e2ca025df45c918beb367f3e20a0a8e91967f6e96c/coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c", size = 250786, upload-time = "2025-12-28T15:40:56.059Z" }, - { url = "https://files.pythonhosted.org/packages/ea/b4/694159c15c52b9f7ec7adf49d50e5f8ee71d3e9ef38adb4445d13dd56c20/coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62", size = 253543, upload-time = "2025-12-28T15:40:57.585Z" }, - { url = "https://files.pythonhosted.org/packages/96/b2/7f1f0437a5c855f87e17cf5d0dc35920b6440ff2b58b1ba9788c059c26c8/coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968", size = 254635, upload-time = "2025-12-28T15:40:59.443Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d1/73c3fdb8d7d3bddd9473c9c6a2e0682f09fc3dfbcb9c3f36412a7368bcab/coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e", size = 251202, upload-time = "2025-12-28T15:41:01.328Z" }, - { url = "https://files.pythonhosted.org/packages/66/3c/f0edf75dcc152f145d5598329e864bbbe04ab78660fe3e8e395f9fff010f/coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f", size = 252566, upload-time = "2025-12-28T15:41:03.319Z" }, - { url = "https://files.pythonhosted.org/packages/17/b3/e64206d3c5f7dcbceafd14941345a754d3dbc78a823a6ed526e23b9cdaab/coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee", size = 250711, upload-time = "2025-12-28T15:41:06.411Z" }, - { url = "https://files.pythonhosted.org/packages/dc/ad/28a3eb970a8ef5b479ee7f0c484a19c34e277479a5b70269dc652b730733/coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf", size = 250278, upload-time = "2025-12-28T15:41:08.285Z" }, - { url = "https://files.pythonhosted.org/packages/54/e3/c8f0f1a93133e3e1291ca76cbb63565bd4b5c5df63b141f539d747fff348/coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c", size = 252154, upload-time = "2025-12-28T15:41:09.969Z" }, - { url = "https://files.pythonhosted.org/packages/d0/bf/9939c5d6859c380e405b19e736321f1c7d402728792f4c752ad1adcce005/coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7", size = 221487, upload-time = "2025-12-28T15:41:11.468Z" }, - { url = "https://files.pythonhosted.org/packages/fa/dc/7282856a407c621c2aad74021680a01b23010bb8ebf427cf5eacda2e876f/coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6", size = 222299, upload-time = "2025-12-28T15:41:13.386Z" }, - { url = "https://files.pythonhosted.org/packages/10/79/176a11203412c350b3e9578620013af35bcdb79b651eb976f4a4b32044fa/coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c", size = 220941, upload-time = "2025-12-28T15:41:14.975Z" }, - { url = "https://files.pythonhosted.org/packages/a3/a4/e98e689347a1ff1a7f67932ab535cef82eb5e78f32a9e4132e114bbb3a0a/coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78", size = 218951, upload-time = "2025-12-28T15:41:16.653Z" }, - { url = "https://files.pythonhosted.org/packages/32/33/7cbfe2bdc6e2f03d6b240d23dc45fdaf3fd270aaf2d640be77b7f16989ab/coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b", size = 219325, upload-time = "2025-12-28T15:41:18.609Z" }, - { url = "https://files.pythonhosted.org/packages/59/f6/efdabdb4929487baeb7cb2a9f7dac457d9356f6ad1b255be283d58b16316/coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd", size = 250309, upload-time = "2025-12-28T15:41:20.629Z" }, - { url = "https://files.pythonhosted.org/packages/12/da/91a52516e9d5aea87d32d1523f9cdcf7a35a3b298e6be05d6509ba3cfab2/coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992", size = 252907, upload-time = "2025-12-28T15:41:22.257Z" }, - { url = "https://files.pythonhosted.org/packages/75/38/f1ea837e3dc1231e086db1638947e00d264e7e8c41aa8ecacf6e1e0c05f4/coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4", size = 254148, upload-time = "2025-12-28T15:41:23.87Z" }, - { url = "https://files.pythonhosted.org/packages/7f/43/f4f16b881aaa34954ba446318dea6b9ed5405dd725dd8daac2358eda869a/coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a", size = 250515, upload-time = "2025-12-28T15:41:25.437Z" }, - { url = "https://files.pythonhosted.org/packages/84/34/8cba7f00078bd468ea914134e0144263194ce849ec3baad187ffb6203d1c/coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766", size = 252292, upload-time = "2025-12-28T15:41:28.459Z" }, - { url = "https://files.pythonhosted.org/packages/8c/a4/cffac66c7652d84ee4ac52d3ccb94c015687d3b513f9db04bfcac2ac800d/coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4", size = 250242, upload-time = "2025-12-28T15:41:30.02Z" }, - { url = "https://files.pythonhosted.org/packages/f4/78/9a64d462263dde416f3c0067efade7b52b52796f489b1037a95b0dc389c9/coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398", size = 250068, upload-time = "2025-12-28T15:41:32.007Z" }, - { url = "https://files.pythonhosted.org/packages/69/c8/a8994f5fece06db7c4a97c8fc1973684e178599b42e66280dded0524ef00/coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784", size = 251846, upload-time = "2025-12-28T15:41:33.946Z" }, - { url = "https://files.pythonhosted.org/packages/cc/f7/91fa73c4b80305c86598a2d4e54ba22df6bf7d0d97500944af7ef155d9f7/coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461", size = 221512, upload-time = "2025-12-28T15:41:35.519Z" }, - { url = "https://files.pythonhosted.org/packages/45/0b/0768b4231d5a044da8f75e097a8714ae1041246bb765d6b5563bab456735/coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500", size = 222321, upload-time = "2025-12-28T15:41:37.371Z" }, - { url = "https://files.pythonhosted.org/packages/9b/b8/bdcb7253b7e85157282450262008f1366aa04663f3e3e4c30436f596c3e2/coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9", size = 220949, upload-time = "2025-12-28T15:41:39.553Z" }, - { url = "https://files.pythonhosted.org/packages/70/52/f2be52cc445ff75ea8397948c96c1b4ee14f7f9086ea62fc929c5ae7b717/coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc", size = 219643, upload-time = "2025-12-28T15:41:41.567Z" }, - { url = "https://files.pythonhosted.org/packages/47/79/c85e378eaa239e2edec0c5523f71542c7793fe3340954eafb0bc3904d32d/coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a", size = 219997, upload-time = "2025-12-28T15:41:43.418Z" }, - { url = "https://files.pythonhosted.org/packages/fe/9b/b1ade8bfb653c0bbce2d6d6e90cc6c254cbb99b7248531cc76253cb4da6d/coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4", size = 261296, upload-time = "2025-12-28T15:41:45.207Z" }, - { url = "https://files.pythonhosted.org/packages/1f/af/ebf91e3e1a2473d523e87e87fd8581e0aa08741b96265730e2d79ce78d8d/coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6", size = 263363, upload-time = "2025-12-28T15:41:47.163Z" }, - { url = "https://files.pythonhosted.org/packages/c4/8b/fb2423526d446596624ac7fde12ea4262e66f86f5120114c3cfd0bb2befa/coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1", size = 265783, upload-time = "2025-12-28T15:41:49.03Z" }, - { url = "https://files.pythonhosted.org/packages/9b/26/ef2adb1e22674913b89f0fe7490ecadcef4a71fa96f5ced90c60ec358789/coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd", size = 260508, upload-time = "2025-12-28T15:41:51.035Z" }, - { url = "https://files.pythonhosted.org/packages/ce/7d/f0f59b3404caf662e7b5346247883887687c074ce67ba453ea08c612b1d5/coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c", size = 263357, upload-time = "2025-12-28T15:41:52.631Z" }, - { url = "https://files.pythonhosted.org/packages/1a/b1/29896492b0b1a047604d35d6fa804f12818fa30cdad660763a5f3159e158/coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0", size = 260978, upload-time = "2025-12-28T15:41:54.589Z" }, - { url = "https://files.pythonhosted.org/packages/48/f2/971de1238a62e6f0a4128d37adadc8bb882ee96afbe03ff1570291754629/coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e", size = 259877, upload-time = "2025-12-28T15:41:56.263Z" }, - { url = "https://files.pythonhosted.org/packages/6a/fc/0474efcbb590ff8628830e9aaec5f1831594874360e3251f1fdec31d07a3/coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53", size = 262069, upload-time = "2025-12-28T15:41:58.093Z" }, - { url = "https://files.pythonhosted.org/packages/88/4f/3c159b7953db37a7b44c0eab8a95c37d1aa4257c47b4602c04022d5cb975/coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842", size = 222184, upload-time = "2025-12-28T15:41:59.763Z" }, - { url = "https://files.pythonhosted.org/packages/58/a5/6b57d28f81417f9335774f20679d9d13b9a8fb90cd6160957aa3b54a2379/coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2", size = 223250, upload-time = "2025-12-28T15:42:01.52Z" }, - { url = "https://files.pythonhosted.org/packages/81/7c/160796f3b035acfbb58be80e02e484548595aa67e16a6345e7910ace0a38/coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09", size = 221521, upload-time = "2025-12-28T15:42:03.275Z" }, - { url = "https://files.pythonhosted.org/packages/aa/8e/ba0e597560c6563fc0adb902fda6526df5d4aa73bb10adf0574d03bd2206/coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894", size = 218996, upload-time = "2025-12-28T15:42:04.978Z" }, - { url = "https://files.pythonhosted.org/packages/6b/8e/764c6e116f4221dc7aa26c4061181ff92edb9c799adae6433d18eeba7a14/coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a", size = 219326, upload-time = "2025-12-28T15:42:06.691Z" }, - { url = "https://files.pythonhosted.org/packages/4f/a6/6130dc6d8da28cdcbb0f2bf8865aeca9b157622f7c0031e48c6cf9a0e591/coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f", size = 250374, upload-time = "2025-12-28T15:42:08.786Z" }, - { url = "https://files.pythonhosted.org/packages/82/2b/783ded568f7cd6b677762f780ad338bf4b4750205860c17c25f7c708995e/coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909", size = 252882, upload-time = "2025-12-28T15:42:10.515Z" }, - { url = "https://files.pythonhosted.org/packages/cd/b2/9808766d082e6a4d59eb0cc881a57fc1600eb2c5882813eefff8254f71b5/coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4", size = 254218, upload-time = "2025-12-28T15:42:12.208Z" }, - { url = "https://files.pythonhosted.org/packages/44/ea/52a985bb447c871cb4d2e376e401116520991b597c85afdde1ea9ef54f2c/coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75", size = 250391, upload-time = "2025-12-28T15:42:14.21Z" }, - { url = "https://files.pythonhosted.org/packages/7f/1d/125b36cc12310718873cfc8209ecfbc1008f14f4f5fa0662aa608e579353/coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9", size = 252239, upload-time = "2025-12-28T15:42:16.292Z" }, - { url = "https://files.pythonhosted.org/packages/6a/16/10c1c164950cade470107f9f14bbac8485f8fb8515f515fca53d337e4a7f/coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465", size = 250196, upload-time = "2025-12-28T15:42:18.54Z" }, - { url = "https://files.pythonhosted.org/packages/2a/c6/cd860fac08780c6fd659732f6ced1b40b79c35977c1356344e44d72ba6c4/coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864", size = 250008, upload-time = "2025-12-28T15:42:20.365Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3a/a8c58d3d38f82a5711e1e0a67268362af48e1a03df27c03072ac30feefcf/coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9", size = 251671, upload-time = "2025-12-28T15:42:22.114Z" }, - { url = "https://files.pythonhosted.org/packages/f0/bc/fd4c1da651d037a1e3d53e8cb3f8182f4b53271ffa9a95a2e211bacc0349/coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5", size = 221777, upload-time = "2025-12-28T15:42:23.919Z" }, - { url = "https://files.pythonhosted.org/packages/4b/50/71acabdc8948464c17e90b5ffd92358579bd0910732c2a1c9537d7536aa6/coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a", size = 222592, upload-time = "2025-12-28T15:42:25.619Z" }, - { url = "https://files.pythonhosted.org/packages/f7/c8/a6fb943081bb0cc926499c7907731a6dc9efc2cbdc76d738c0ab752f1a32/coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0", size = 221169, upload-time = "2025-12-28T15:42:27.629Z" }, - { url = "https://files.pythonhosted.org/packages/16/61/d5b7a0a0e0e40d62e59bc8c7aa1afbd86280d82728ba97f0673b746b78e2/coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a", size = 219730, upload-time = "2025-12-28T15:42:29.306Z" }, - { url = "https://files.pythonhosted.org/packages/a3/2c/8881326445fd071bb49514d1ce97d18a46a980712b51fee84f9ab42845b4/coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6", size = 220001, upload-time = "2025-12-28T15:42:31.319Z" }, - { url = "https://files.pythonhosted.org/packages/b5/d7/50de63af51dfa3a7f91cc37ad8fcc1e244b734232fbc8b9ab0f3c834a5cd/coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673", size = 261370, upload-time = "2025-12-28T15:42:32.992Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2c/d31722f0ec918fd7453b2758312729f645978d212b410cd0f7c2aed88a94/coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5", size = 263485, upload-time = "2025-12-28T15:42:34.759Z" }, - { url = "https://files.pythonhosted.org/packages/fa/7a/2c114fa5c5fc08ba0777e4aec4c97e0b4a1afcb69c75f1f54cff78b073ab/coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d", size = 265890, upload-time = "2025-12-28T15:42:36.517Z" }, - { url = "https://files.pythonhosted.org/packages/65/d9/f0794aa1c74ceabc780fe17f6c338456bbc4e96bd950f2e969f48ac6fb20/coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8", size = 260445, upload-time = "2025-12-28T15:42:38.646Z" }, - { url = "https://files.pythonhosted.org/packages/49/23/184b22a00d9bb97488863ced9454068c79e413cb23f472da6cbddc6cfc52/coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486", size = 263357, upload-time = "2025-12-28T15:42:40.788Z" }, - { url = "https://files.pythonhosted.org/packages/7d/bd/58af54c0c9199ea4190284f389005779d7daf7bf3ce40dcd2d2b2f96da69/coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564", size = 260959, upload-time = "2025-12-28T15:42:42.808Z" }, - { url = "https://files.pythonhosted.org/packages/4b/2a/6839294e8f78a4891bf1df79d69c536880ba2f970d0ff09e7513d6e352e9/coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7", size = 259792, upload-time = "2025-12-28T15:42:44.818Z" }, - { url = "https://files.pythonhosted.org/packages/ba/c3/528674d4623283310ad676c5af7414b9850ab6d55c2300e8aa4b945ec554/coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416", size = 262123, upload-time = "2025-12-28T15:42:47.108Z" }, - { url = "https://files.pythonhosted.org/packages/06/c5/8c0515692fb4c73ac379d8dc09b18eaf0214ecb76ea6e62467ba7a1556ff/coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f", size = 222562, upload-time = "2025-12-28T15:42:49.144Z" }, - { url = "https://files.pythonhosted.org/packages/05/0e/c0a0c4678cb30dac735811db529b321d7e1c9120b79bd728d4f4d6b010e9/coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79", size = 223670, upload-time = "2025-12-28T15:42:51.218Z" }, - { url = "https://files.pythonhosted.org/packages/f5/5f/b177aa0011f354abf03a8f30a85032686d290fdeed4222b27d36b4372a50/coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4", size = 221707, upload-time = "2025-12-28T15:42:53.034Z" }, - { url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" }, + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'emscripten'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/d4/7827d9ffa34d5d4d752eec907022aa417120936282fc488306f5da08c292/coverage-7.13.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fc31c787a84f8cd6027eba44010517020e0d18487064cd3d8968941856d1415", size = 219152, upload-time = "2026-02-09T12:56:11.974Z" }, + { url = "https://files.pythonhosted.org/packages/35/b0/d69df26607c64043292644dbb9dc54b0856fabaa2cbb1eeee3331cc9e280/coverage-7.13.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a32ebc02a1805adf637fc8dec324b5cdacd2e493515424f70ee33799573d661b", size = 219667, upload-time = "2026-02-09T12:56:13.33Z" }, + { url = "https://files.pythonhosted.org/packages/82/a4/c1523f7c9e47b2271dbf8c2a097e7a1f89ef0d66f5840bb59b7e8814157b/coverage-7.13.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e24f9156097ff9dc286f2f913df3a7f63c0e333dcafa3c196f2c18b4175ca09a", size = 246425, upload-time = "2026-02-09T12:56:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/f8/02/aa7ec01d1a5023c4b680ab7257f9bfde9defe8fdddfe40be096ac19e8177/coverage-7.13.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8041b6c5bfdc03257666e9881d33b1abc88daccaf73f7b6340fb7946655cd10f", size = 248229, upload-time = "2026-02-09T12:56:16.31Z" }, + { url = "https://files.pythonhosted.org/packages/35/98/85aba0aed5126d896162087ef3f0e789a225697245256fc6181b95f47207/coverage-7.13.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a09cfa6a5862bc2fc6ca7c3def5b2926194a56b8ab78ffcf617d28911123012", size = 250106, upload-time = "2026-02-09T12:56:18.024Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1db59bd67494bc162e3e4cd5fbc7edba2c7026b22f7c8ef1496d58c2b94c/coverage-7.13.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:296f8b0af861d3970c2a4d8c91d48eb4dd4771bcef9baedec6a9b515d7de3def", size = 252021, upload-time = "2026-02-09T12:56:19.272Z" }, + { url = "https://files.pythonhosted.org/packages/9d/97/72899c59c7066961de6e3daa142d459d47d104956db43e057e034f015c8a/coverage-7.13.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e101609bcbbfb04605ea1027b10dc3735c094d12d40826a60f897b98b1c30256", size = 247114, upload-time = "2026-02-09T12:56:21.051Z" }, + { url = "https://files.pythonhosted.org/packages/39/1f/f1885573b5970235e908da4389176936c8933e86cb316b9620aab1585fa2/coverage-7.13.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aa3feb8db2e87ff5e6d00d7e1480ae241876286691265657b500886c98f38bda", size = 248143, upload-time = "2026-02-09T12:56:22.585Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cf/e80390c5b7480b722fa3e994f8202807799b85bc562aa4f1dde209fbb7be/coverage-7.13.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4fc7fa81bbaf5a02801b65346c8b3e657f1d93763e58c0abdf7c992addd81a92", size = 246152, upload-time = "2026-02-09T12:56:23.748Z" }, + { url = "https://files.pythonhosted.org/packages/44/bf/f89a8350d85572f95412debb0fb9bb4795b1d5b5232bd652923c759e787b/coverage-7.13.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:33901f604424145c6e9c2398684b92e176c0b12df77d52db81c20abd48c3794c", size = 249959, upload-time = "2026-02-09T12:56:25.209Z" }, + { url = "https://files.pythonhosted.org/packages/f7/6e/612a02aece8178c818df273e8d1642190c4875402ca2ba74514394b27aba/coverage-7.13.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:bb28c0f2cf2782508a40cec377935829d5fcc3ad9a3681375af4e84eb34b6b58", size = 246416, upload-time = "2026-02-09T12:56:26.475Z" }, + { url = "https://files.pythonhosted.org/packages/cb/98/b5afc39af67c2fa6786b03c3a7091fc300947387ce8914b096db8a73d67a/coverage-7.13.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d107aff57a83222ddbd8d9ee705ede2af2cc926608b57abed8ef96b50b7e8f9", size = 247025, upload-time = "2026-02-09T12:56:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/51/30/2bba8ef0682d5bd210c38fe497e12a06c9f8d663f7025e9f5c2c31ce847d/coverage-7.13.4-cp310-cp310-win32.whl", hash = "sha256:a6f94a7d00eb18f1b6d403c91a88fd58cfc92d4b16080dfdb774afc8294469bf", size = 221758, upload-time = "2026-02-09T12:56:29.051Z" }, + { url = "https://files.pythonhosted.org/packages/78/13/331f94934cf6c092b8ea59ff868eb587bc8fe0893f02c55bc6c0183a192e/coverage-7.13.4-cp310-cp310-win_amd64.whl", hash = "sha256:2cb0f1e000ebc419632bbe04366a8990b6e32c4e0b51543a6484ffe15eaeda95", size = 222693, upload-time = "2026-02-09T12:56:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" }, + { url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" }, + { url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" }, + { url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" }, + { url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" }, + { url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" }, + { url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" }, + { url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" }, + { url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" }, + { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, + { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, + { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, + { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, + { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, + { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, + { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, + { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, + { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, + { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, + { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, + { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, + { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, + { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, + { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, + { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, + { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, + { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, + { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, + { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, + { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, + { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, + { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, + { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, + { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, + { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, + { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, + { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, + { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, + { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, + { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, + { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, + { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, ] [package.optional-dependencies] @@ -529,18 +570,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/c7/d1ec24fb280caa5a79b6b950db565dab30210a66259d17d5bb2b3a9f878d/dependency_groups-1.3.1-py3-none-any.whl", hash = "sha256:51aeaa0dfad72430fcfb7bcdbefbd75f3792e5919563077f30bc0d73f4493030", size = 8664, upload-time = "2025-05-02T00:34:27.085Z" }, ] -[[package]] -name = "deprecation" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging", marker = "python_full_version < '3.9'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5a/d3/8ae2869247df154b64c1884d7346d412fed0c49df84db635aab2d1c40e62/deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff", size = 173788, upload-time = "2020-04-20T14:23:38.738Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178, upload-time = "2020-04-20T14:23:36.581Z" }, -] - [[package]] name = "distlib" version = "0.4.0" @@ -555,11 +584,9 @@ name = "docker" version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pywin32", marker = "sys_platform == 'win32'" }, - { name = "requests", version = "2.32.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, - { name = "urllib3", version = "2.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "urllib3", version = "2.6.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "pywin32", marker = "python_full_version >= '3.9' and sys_platform == 'win32'" }, + { name = "requests", marker = "python_full_version >= '3.9'" }, + { name = "urllib3", marker = "python_full_version >= '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" } wheels = [ @@ -572,13 +599,22 @@ version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.13'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + [[package]] name = "filelock" version = "3.16.1" @@ -606,14 +642,23 @@ wheels = [ [[package]] name = "filelock" -version = "3.20.2" +version = "3.24.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.10'", -] -sdist = { url = "https://files.pythonhosted.org/packages/c1/e0/a75dbe4bca1e7d41307323dad5ea2efdd95408f74ab2de8bd7dba9b51a1a/filelock-3.20.2.tar.gz", hash = "sha256:a2241ff4ddde2a7cebddf78e39832509cb045d18ec1a09d7248d6bfc6bfbbe64", size = 19510, upload-time = "2026-01-02T15:33:32.582Z" } + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'emscripten'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/73/92/a8e2479937ff39185d20dd6a851c1a63e55849e447a55e798cc2e1f49c65/filelock-3.24.3.tar.gz", hash = "sha256:011a5644dc937c22699943ebbfc46e969cdde3e171470a6e40b9533e5a72affa", size = 37935, upload-time = "2026-02-19T00:48:20.543Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/30/ab407e2ec752aa541704ed8f93c11e2a5d92c168b8a755d818b74a3c5c2d/filelock-3.20.2-py3-none-any.whl", hash = "sha256:fbba7237d6ea277175a32c54bb71ef814a8546d8601269e1bfc388de333974e8", size = 16697, upload-time = "2026-01-02T15:33:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0f/5d0c71a1aefeb08efff26272149e07ab922b64f46c63363756224bd6872e/filelock-3.24.3-py3-none-any.whl", hash = "sha256:426e9a4660391f7f8a810d71b0555bce9008b0a1cc342ab1f6947d37639e002d", size = 24331, upload-time = "2026-02-19T00:48:18.465Z" }, ] [[package]] @@ -646,13 +691,82 @@ name = "humanize" version = "4.15.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.10'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'emscripten'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.10.*'", ] sdist = { url = "https://files.pythonhosted.org/packages/ba/66/a3921783d54be8a6870ac4ccffcd15c4dc0dd7fcce51c6d63b8c63935276/humanize-4.15.0.tar.gz", hash = "sha256:1dd098483eb1c7ee8e32eb2e99ad1910baefa4b75c3aff3a82f4d78688993b10", size = 83599, upload-time = "2025-12-20T20:16:13.19Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c5/7b/bca5613a0c3b542420cf92bd5e5fb8ebd5435ce1011a091f66bb7693285e/humanize-4.15.0-py3-none-any.whl", hash = "sha256:b1186eb9f5a9749cd9cb8565aee77919dd7c8d076161cf44d70e59e3301e1769", size = 132203, upload-time = "2025-12-20T20:16:11.67Z" }, ] +[[package]] +name = "hypothesis" +version = "6.113.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +dependencies = [ + { name = "attrs", version = "25.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.9'" }, + { name = "sortedcontainers", marker = "python_full_version < '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/32/6513cd7256f38c19a6c8a1d5ce9792bcd35c7f11651989994731f0e97672/hypothesis-6.113.0.tar.gz", hash = "sha256:5556ac66fdf72a4ccd5d237810f7cf6bdcd00534a4485015ef881af26e20f7c7", size = 408897, upload-time = "2024-10-09T03:51:05.707Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/fa/4acb477b86a94571958bd337eae5baf334d21b8c98a04b594d0dad381ba8/hypothesis-6.113.0-py3-none-any.whl", hash = "sha256:d539180eb2bb71ed28a23dfe94e67c851f9b09f3ccc4125afad43f17e32e2bad", size = 469790, upload-time = "2024-10-09T03:51:02.629Z" }, +] + +[[package]] +name = "hypothesis" +version = "6.141.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9.2' and python_full_version < '3.10'", + "python_full_version >= '3.9' and python_full_version < '3.9.2'", +] +dependencies = [ + { name = "attrs", version = "25.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "exceptiongroup", marker = "python_full_version == '3.9.*'" }, + { name = "sortedcontainers", marker = "python_full_version == '3.9.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/85/20/8aa62b3e69fea68bb30d35d50be5395c98979013acd8152d64dc927e4cdb/hypothesis-6.141.1.tar.gz", hash = "sha256:8ef356e1e18fbeaa8015aab3c805303b7fe4b868e5b506e87ad83c0bf951f46f", size = 467389, upload-time = "2025-10-15T19:12:25.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/9a/f901858f139694dd669776983781b08a7c1717911025da6720e526bd8ce3/hypothesis-6.141.1-py3-none-any.whl", hash = "sha256:a5b3c39c16d98b7b4c3c5c8d4262e511e3b2255e6814ced8023af49087ad60b3", size = 535000, upload-time = "2025-10-15T19:12:21.659Z" }, +] + +[[package]] +name = "hypothesis" +version = "6.151.9" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'emscripten'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, + { name = "sortedcontainers", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/e1/ef365ff480903b929d28e057f57b76cae51a30375943e33374ec9a165d9c/hypothesis-6.151.9.tar.gz", hash = "sha256:2f284428dda6c3c48c580de0e18470ff9c7f5ef628a647ee8002f38c3f9097ca", size = 463534, upload-time = "2026-02-16T22:59:23.09Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/f7/5cc291d701094754a1d327b44d80a44971e13962881d9a400235726171da/hypothesis-6.151.9-py3-none-any.whl", hash = "sha256:7b7220585c67759b1b1ef839b1e6e9e3d82ed468cfc1ece43c67184848d7edd9", size = 529307, upload-time = "2026-02-16T22:59:20.443Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -681,7 +795,16 @@ name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.10'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'emscripten'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.10.*'", ] 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 = [ @@ -690,7 +813,7 @@ wheels = [ [[package]] name = "nox" -version = "2025.11.12" +version = "2026.2.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "argcomplete" }, @@ -705,30 +828,30 @@ dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b4/a8/e169497599266d176832e2232c08557ffba97eef87bf8a18f9f918e0c6aa/nox-2025.11.12.tar.gz", hash = "sha256:3d317f9e61f49d6bde39cf2f59695bb4e1722960457eee3ae19dacfe03c07259", size = 4030561, upload-time = "2025-11-12T18:39:03.319Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/8e/55a9679b31f1efc48facedd2448eb53c7f1e647fb592aa1403c9dd7a4590/nox-2026.2.9.tar.gz", hash = "sha256:1bc8a202ee8cd69be7aaada63b2a7019126899a06fc930a7aee75585bf8ee41b", size = 4031165, upload-time = "2026-02-10T04:38:58.878Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/34/434c594e0125a16b05a7bedaea33e63c90abbfbe47e5729a735a8a8a90ea/nox-2025.11.12-py3-none-any.whl", hash = "sha256:707171f9f63bc685da9d00edd8c2ceec8405b8e38b5fb4e46114a860070ef0ff", size = 74447, upload-time = "2025-11-12T18:39:01.575Z" }, + { url = "https://files.pythonhosted.org/packages/8d/58/0d5e5a044f1868bdc45f38afdc2d90ff9867ce398b4e8fa9e666bfc9bfba/nox-2026.2.9-py3-none-any.whl", hash = "sha256:1b7143bc8ecdf25f2353201326152c5303ae4ae56ca097b1fb6179ad75164c47", size = 74615, upload-time = "2026-02-10T04:38:57.266Z" }, ] [[package]] name = "nox-uv" -version = "0.7.0" +version = "0.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nox", marker = "python_full_version >= '3.9'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/a2/be2f4d0fd1632213eb5962c8e5ee27def06305403786e21fc3179c7e26e6/nox_uv-0.7.0.tar.gz", hash = "sha256:60ac21c16650f05ebb520d737cc2e838c7a49be2ad1dbcd7165ffd3675560991", size = 5077, upload-time = "2026-01-03T19:54:59.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/e8/670919c513c22f4bf1656d84dd99a9ad1a5eaaeadf2457bab3efeeac14e0/nox_uv-0.7.1.tar.gz", hash = "sha256:f075d610b4648732fd17cbc9fa48be7d2c23df7b188fed3e4e6dde7bd1f14f20", size = 5124, upload-time = "2026-02-05T03:55:34.807Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/86/94/afa119b031c08e74a2e078260c5cdbb56b53949e5d2a0158215e8c265e12/nox_uv-0.7.0-py3-none-any.whl", hash = "sha256:51f9bb68ca6d721706f6372f0dec8ebfcc00408c2172b2bfeb6a6c06d9e3ab38", size = 5408, upload-time = "2026-01-03T19:54:57.798Z" }, + { url = "https://files.pythonhosted.org/packages/4f/0a/a6798a215366c9b034e92a9992d9013da5f544a488216fc54204ccf3c134/nox_uv-0.7.1-py3-none-any.whl", hash = "sha256:91361cc282a0a764de1b94ad002b67d5b43de4adc3f56e16d1b79928c8ec0433", size = 5457, upload-time = "2026-02-05T03:55:35.994Z" }, ] [[package]] name = "packaging" -version = "25.0" +version = "26.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] [[package]] @@ -758,14 +881,23 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.5.1" +version = "4.9.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.10'", -] -sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'emscripten'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, + { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" }, ] [[package]] @@ -785,7 +917,16 @@ name = "pluggy" version = "1.6.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.10'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'emscripten'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.10.*'", "python_full_version >= '3.9.2' and python_full_version < '3.10'", "python_full_version >= '3.9' and python_full_version < '3.9.2'", ] @@ -794,6 +935,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "py-cpuinfo" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/a8/d832f7293ebb21690860d2e01d8115e5ff6f2ae8bbdc953f0eb0fa4bd2c7/py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690", size = 104716, upload-time = "2022-10-25T20:38:06.303Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size = 22335, upload-time = "2022-10-25T20:38:27.636Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -850,7 +1000,16 @@ name = "pytest" version = "9.0.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.10'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'emscripten'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.10.*'", ] dependencies = [ { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, @@ -904,7 +1063,16 @@ name = "pytest-asyncio" version = "1.3.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.10'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'emscripten'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.10.*'", ] dependencies = [ { name = "backports-asyncio-runner", marker = "python_full_version == '3.10.*'" }, @@ -916,6 +1084,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] +[[package]] +name = "pytest-benchmark" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +dependencies = [ + { name = "py-cpuinfo", marker = "python_full_version < '3.9'" }, + { name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/08/e6b0067efa9a1f2a1eb3043ecd8a0c48bfeb60d3255006dcc829d72d5da2/pytest-benchmark-4.0.0.tar.gz", hash = "sha256:fb0785b83efe599a6a956361c0691ae1dbb5318018561af10f3e915caa0048d1", size = 334641, upload-time = "2022-10-25T21:21:55.686Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/a1/3b70862b5b3f830f0422844f25a823d0470739d994466be9dbbbb414d85a/pytest_benchmark-4.0.0-py3-none-any.whl", hash = "sha256:fdb7db64e31c8b277dff9850d2a2556d8b60bcb0ea6524e36e28ffd7c87f71d6", size = 43951, upload-time = "2022-10-25T21:21:53.208Z" }, +] + +[[package]] +name = "pytest-benchmark" +version = "5.2.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'emscripten'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.10.*'", + "python_full_version >= '3.9.2' and python_full_version < '3.10'", + "python_full_version >= '3.9' and python_full_version < '3.9.2'", +] +dependencies = [ + { name = "py-cpuinfo", marker = "python_full_version >= '3.9'" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/34/9f732b76456d64faffbef6232f1f9dbec7a7c4999ff46282fa418bd1af66/pytest_benchmark-5.2.3.tar.gz", hash = "sha256:deb7317998a23c650fd4ff76e1230066a76cb45dcece0aca5607143c619e7779", size = 341340, upload-time = "2025-11-09T18:48:43.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/29/e756e715a48959f1c0045342088d7ca9762a2f509b945f362a316e9412b7/pytest_benchmark-5.2.3-py3-none-any.whl", hash = "sha256:bc839726ad20e99aaa0d11a127445457b4219bdb9e80a1afc4b51da7f96b0803", size = 45255, upload-time = "2025-11-09T18:48:39.765Z" }, +] + [[package]] name = "pytest-cov" version = "5.0.0" @@ -937,13 +1149,22 @@ name = "pytest-cov" version = "7.0.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.10'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'emscripten'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.10.*'", "python_full_version >= '3.9.2' and python_full_version < '3.10'", "python_full_version >= '3.9' and python_full_version < '3.9.2'", ] dependencies = [ { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version == '3.9.*'" }, - { name = "coverage", version = "7.13.1", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version >= '3.10'" }, + { name = "coverage", version = "7.13.4", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version >= '3.10'" }, { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, @@ -973,7 +1194,16 @@ name = "pytest-mock" version = "3.15.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.10'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'emscripten'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.10.*'", "python_full_version >= '3.9.2' and python_full_version < '3.10'", "python_full_version >= '3.9' and python_full_version < '3.9.2'", ] @@ -1000,6 +1230,67 @@ 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 = "pytest-xdist" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +dependencies = [ + { name = "execnet", marker = "python_full_version < '3.9'" }, + { name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/c4/3c310a19bc1f1e9ef50075582652673ef2bfc8cd62afef9585683821902f/pytest_xdist-3.6.1.tar.gz", hash = "sha256:ead156a4db231eec769737f57668ef58a2084a34b2e55c4a8fa20d861107300d", size = 84060, upload-time = "2024-04-28T19:29:54.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/82/1d96bf03ee4c0fdc3c0cbe61470070e659ca78dc0086fb88b66c185e2449/pytest_xdist-3.6.1-py3-none-any.whl", hash = "sha256:9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7", size = 46108, upload-time = "2024-04-28T19:29:52.813Z" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'emscripten'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.10.*'", + "python_full_version >= '3.9.2' and python_full_version < '3.10'", + "python_full_version >= '3.9' and python_full_version < '3.9.2'", +] +dependencies = [ + { name = "execnet", marker = "python_full_version >= '3.9'" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock", version = "3.16.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "filelock", version = "3.19.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "filelock", version = "3.24.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "platformdirs", version = "4.3.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "platformdirs", version = "4.9.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/bb/93a3e83bdf9322c7e21cafd092e56a4a17c4d8ef4277b6eb01af1a540a6f/python_discovery-1.1.0.tar.gz", hash = "sha256:447941ba1aed8cc2ab7ee3cb91be5fc137c5bdbb05b7e6ea62fbdcb66e50b268", size = 55674, upload-time = "2026-02-26T09:42:49.668Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/54/82a6e2ef37f0f23dccac604b9585bdcbd0698604feb64807dcb72853693e/python_discovery-1.1.0-py3-none-any.whl", hash = "sha256:a162893b8809727f54594a99ad2179d2ede4bf953e12d4c7abc3cc9cdbd1437b", size = 30687, upload-time = "2026-02-26T09:42:48.548Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.1" @@ -1018,7 +1309,10 @@ source = { editable = "." } dev = [ { name = "coverage", version = "7.6.1", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version < '3.9'" }, { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version == '3.9.*'" }, - { name = "coverage", version = "7.13.1", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version >= '3.10'" }, + { name = "coverage", version = "7.13.4", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version >= '3.10'" }, + { name = "hypothesis", version = "6.113.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "hypothesis", version = "6.141.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "hypothesis", version = "6.151.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "nox" }, { name = "nox-uv", marker = "python_full_version >= '3.9'" }, { name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, @@ -1027,17 +1321,20 @@ dev = [ { name = "pytest-asyncio", version = "0.24.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "pytest-asyncio", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, { name = "pytest-asyncio", version = "1.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest-benchmark", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "pytest-benchmark", version = "5.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "pytest-cov", version = "5.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "pytest-cov", version = "7.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "pytest-mock", version = "3.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "pytest-mock", version = "3.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "pytest-timeout" }, + { name = "pytest-xdist", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "pytest-xdist", version = "3.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "ruff" }, - { name = "setuptools", version = "75.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "setuptools", version = "80.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, - { name = "testcontainers", version = "3.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "setuptools", marker = "python_full_version >= '3.9'" }, { name = "testcontainers", version = "4.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.9.2'" }, - { name = "testcontainers", version = "4.13.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9.2'" }, + { name = "testcontainers", version = "4.13.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9.2' and python_full_version < '3.10'" }, + { name = "testcontainers", version = "4.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "ty" }, ] @@ -1046,16 +1343,19 @@ dev = [ [package.metadata.requires-dev] dev = [ { name = "coverage", extras = ["toml"], specifier = ">=7.0.0" }, + { name = "hypothesis", specifier = ">=6.0.0" }, { name = "nox", specifier = ">=2024.0.0" }, { name = "nox-uv", marker = "python_full_version >= '3.9'", specifier = ">=0.2.0" }, { name = "pytest", specifier = ">=7.0.0" }, { name = "pytest-asyncio", specifier = ">=0.21.0" }, + { name = "pytest-benchmark", specifier = ">=4.0.0" }, { name = "pytest-cov", specifier = ">=4.0.0" }, { name = "pytest-mock", specifier = ">=3.10.0" }, { name = "pytest-timeout", specifier = ">=2.0.0" }, + { name = "pytest-xdist", specifier = ">=3.0.0" }, { name = "ruff", specifier = ">=0.1.0" }, - { name = "setuptools", specifier = ">=75.3.2" }, - { name = "testcontainers", specifier = ">=3.7.0" }, + { name = "setuptools", marker = "python_full_version >= '3.9'", specifier = ">=78.1.1" }, + { name = "testcontainers", marker = "python_full_version >= '3.9'", specifier = ">=4.0.0" }, { name = "ty", specifier = ">=0.0.8" }, ] @@ -1086,38 +1386,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/22/e0e8d802f124772cec9c75430b01a212f86f9de7546bda715e54140d5aeb/pywin32-311-cp39-cp39-win_arm64.whl", hash = "sha256:62ea666235135fee79bb154e695f3ff67370afefd71bd7fea7512fc70ef31e3d", size = 8778162, upload-time = "2025-07-14T20:13:03.544Z" }, ] -[[package]] -name = "requests" -version = "2.32.4" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -dependencies = [ - { name = "certifi", marker = "python_full_version < '3.9'" }, - { name = "charset-normalizer", marker = "python_full_version < '3.9'" }, - { name = "idna", marker = "python_full_version < '3.9'" }, - { name = "urllib3", version = "2.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258, upload-time = "2025-06-09T16:43:07.34Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847, upload-time = "2025-06-09T16:43:05.728Z" }, -] - [[package]] name = "requests" version = "2.32.5" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version >= '3.9.2' and python_full_version < '3.10'", - "python_full_version >= '3.9' and python_full_version < '3.9.2'", -] dependencies = [ { name = "certifi", marker = "python_full_version >= '3.9'" }, { name = "charset-normalizer", marker = "python_full_version >= '3.9'" }, { name = "idna", marker = "python_full_version >= '3.9'" }, - { name = "urllib3", version = "2.6.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "urllib3", marker = "python_full_version >= '3.9'" }, ] 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 = [ @@ -1126,70 +1403,45 @@ wheels = [ [[package]] name = "ruff" -version = "0.14.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" }, - { url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" }, - { url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" }, - { url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" }, - { url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" }, - { url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" }, - { url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" }, - { url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" }, - { url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" }, - { url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" }, - { url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" }, - { url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" }, - { url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" }, - { url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" }, - { url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" }, - { url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" }, - { url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" }, - { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" }, -] - -[[package]] -name = "setuptools" -version = "75.3.2" +version = "0.15.4" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/5c/01/771ea46cce201dd42cff043a5eea929d1c030fb3d1c2ee2729d02ca7814c/setuptools-75.3.2.tar.gz", hash = "sha256:3c1383e1038b68556a382c1e8ded8887cd20141b0eb5708a6c8d277de49364f5", size = 1354489, upload-time = "2025-03-12T00:02:19.004Z" } +sdist = { url = "https://files.pythonhosted.org/packages/da/31/d6e536cdebb6568ae75a7f00e4b4819ae0ad2640c3604c305a0428680b0c/ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1", size = 4569550, upload-time = "2026-02-26T20:04:14.959Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/15/65/3f0dba35760d902849d39d38c0a72767794b1963227b69a587f8a336d08c/setuptools-75.3.2-py3-none-any.whl", hash = "sha256:90ab613b6583fc02d5369cbca13ea26ea0e182d1df2d943ee9cbe81d4c61add9", size = 1251198, upload-time = "2025-03-12T00:02:17.554Z" }, + { url = "https://files.pythonhosted.org/packages/f2/82/c11a03cfec3a4d26a0ea1e571f0f44be5993b923f905eeddfc397c13d360/ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0", size = 10453333, upload-time = "2026-02-26T20:04:20.093Z" }, + { url = "https://files.pythonhosted.org/packages/ce/5d/6a1f271f6e31dffb31855996493641edc3eef8077b883eaf007a2f1c2976/ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992", size = 10853356, upload-time = "2026-02-26T20:04:05.808Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d8/0fab9f8842b83b1a9c2bf81b85063f65e93fb512e60effa95b0be49bfc54/ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba", size = 10187434, upload-time = "2026-02-26T20:03:54.656Z" }, + { url = "https://files.pythonhosted.org/packages/85/cc/cc220fd9394eff5db8d94dec199eec56dd6c9f3651d8869d024867a91030/ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75", size = 10535456, upload-time = "2026-02-26T20:03:52.738Z" }, + { url = "https://files.pythonhosted.org/packages/fa/0f/bced38fa5cf24373ec767713c8e4cadc90247f3863605fb030e597878661/ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac", size = 10287772, upload-time = "2026-02-26T20:04:08.138Z" }, + { url = "https://files.pythonhosted.org/packages/2b/90/58a1802d84fed15f8f281925b21ab3cecd813bde52a8ca033a4de8ab0e7a/ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a", size = 11049051, upload-time = "2026-02-26T20:04:03.53Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ac/b7ad36703c35f3866584564dc15f12f91cb1a26a897dc2fd13d7cb3ae1af/ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85", size = 11890494, upload-time = "2026-02-26T20:04:10.497Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/3eb2f47a39a8b0da99faf9c54d3eb24720add1e886a5309d4d1be73a6380/ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db", size = 11326221, upload-time = "2026-02-26T20:04:12.84Z" }, + { url = "https://files.pythonhosted.org/packages/ff/90/bf134f4c1e5243e62690e09d63c55df948a74084c8ac3e48a88468314da6/ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec", size = 11168459, upload-time = "2026-02-26T20:04:00.969Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e5/a64d27688789b06b5d55162aafc32059bb8c989c61a5139a36e1368285eb/ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f", size = 11104366, upload-time = "2026-02-26T20:03:48.099Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f6/32d1dcb66a2559763fc3027bdd65836cad9eb09d90f2ed6a63d8e9252b02/ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338", size = 10510887, upload-time = "2026-02-26T20:03:45.771Z" }, + { url = "https://files.pythonhosted.org/packages/ff/92/22d1ced50971c5b6433aed166fcef8c9343f567a94cf2b9d9089f6aa80fe/ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc", size = 10285939, upload-time = "2026-02-26T20:04:22.42Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f4/7c20aec3143837641a02509a4668fb146a642fd1211846634edc17eb5563/ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68", size = 10765471, upload-time = "2026-02-26T20:03:58.924Z" }, + { url = "https://files.pythonhosted.org/packages/d0/09/6d2f7586f09a16120aebdff8f64d962d7c4348313c77ebb29c566cefc357/ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3", size = 11263382, upload-time = "2026-02-26T20:04:24.424Z" }, + { url = "https://files.pythonhosted.org/packages/1b/fa/2ef715a1cd329ef47c1a050e10dee91a9054b7ce2fcfdd6a06d139afb7ec/ruff-0.15.4-py3-none-win32.whl", hash = "sha256:65594a2d557d4ee9f02834fcdf0a28daa8b3b9f6cb2cb93846025a36db47ef22", size = 10506664, upload-time = "2026-02-26T20:03:50.56Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a8/c688ef7e29983976820d18710f955751d9f4d4eb69df658af3d006e2ba3e/ruff-0.15.4-py3-none-win_amd64.whl", hash = "sha256:04196ad44f0df220c2ece5b0e959c2f37c777375ec744397d21d15b50a75264f", size = 11651048, upload-time = "2026-02-26T20:04:17.191Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0a/9e1be9035b37448ce2e68c978f0591da94389ade5a5abafa4cf99985d1b2/ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453", size = 10966776, upload-time = "2026-02-26T20:03:56.908Z" }, ] [[package]] name = "setuptools" -version = "80.9.0" +version = "82.0.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version >= '3.9.2' and python_full_version < '3.10'", - "python_full_version >= '3.9' and python_full_version < '3.9.2'", -] -sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/f3/748f4d6f65d1756b9ae577f329c951cda23fb900e4de9f70900ced962085/setuptools-82.0.0.tar.gz", hash = "sha256:22e0a2d69474c6ae4feb01951cb69d515ed23728cf96d05513d36e42b62b37cb", size = 1144893, upload-time = "2026-02-08T15:08:40.206Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c6/76dc613121b793286a3f91621d7b75a2b493e0390ddca50f11993eadf192/setuptools-82.0.0-py3-none-any.whl", hash = "sha256:70b18734b607bd1da571d097d236cfcfacaf01de45717d59e6e04b96877532e0", size = 1003468, upload-time = "2026-02-08T15:08:38.723Z" }, ] [[package]] -name = "testcontainers" -version = "3.7.1" +name = "sortedcontainers" +version = "2.4.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -dependencies = [ - { name = "deprecation", marker = "python_full_version < '3.9'" }, - { name = "docker", marker = "python_full_version < '3.9'" }, - { name = "wrapt", marker = "python_full_version < '3.9'" }, -] +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/37/38c595414d764cb1d9f3a0c907878c4146a21505ab974c63bcf3d8145807/testcontainers-3.7.1-py2.py3-none-any.whl", hash = "sha256:7f48cef4bf0ccd78f1a4534d4b701a003a3bace851f24eae58a32f9e3f0aeba0", size = 45321, upload-time = "2022-12-06T17:55:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, ] [[package]] @@ -1203,7 +1455,7 @@ dependencies = [ { name = "docker", marker = "python_full_version >= '3.9' and python_full_version < '3.9.2'" }, { name = "python-dotenv", marker = "python_full_version >= '3.9' and python_full_version < '3.9.2'" }, { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.9.2'" }, - { name = "urllib3", version = "2.6.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.9.2'" }, + { name = "urllib3", marker = "python_full_version >= '3.9' and python_full_version < '3.9.2'" }, { name = "wrapt", marker = "python_full_version >= '3.9' and python_full_version < '3.9.2'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d7/e5/807161552b8bf7072d63a21d5fd3c7df54e29420e325d50b9001571fcbb6/testcontainers-4.13.0.tar.gz", hash = "sha256:ee2bc39324eeeeb710be779208ae070c8373fa9058861859203f536844b0f412", size = 77824, upload-time = "2025-09-09T13:23:49.976Z" } @@ -1216,93 +1468,124 @@ name = "testcontainers" version = "4.13.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.10'", "python_full_version >= '3.9.2' and python_full_version < '3.10'", ] dependencies = [ - { name = "docker", marker = "python_full_version >= '3.9.2'" }, - { name = "python-dotenv", marker = "python_full_version >= '3.9.2'" }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9.2'" }, - { name = "urllib3", version = "2.6.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9.2'" }, - { name = "wrapt", marker = "python_full_version >= '3.9.2'" }, + { name = "docker", marker = "python_full_version >= '3.9.2' and python_full_version < '3.10'" }, + { name = "python-dotenv", marker = "python_full_version >= '3.9.2' and python_full_version < '3.10'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9.2' and python_full_version < '3.10'" }, + { name = "urllib3", marker = "python_full_version >= '3.9.2' and python_full_version < '3.10'" }, + { name = "wrapt", marker = "python_full_version >= '3.9.2' and python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fc/b3/c272537f3ea2f312555efeb86398cc382cd07b740d5f3c730918c36e64e1/testcontainers-4.13.3.tar.gz", hash = "sha256:9d82a7052c9a53c58b69e1dc31da8e7a715e8b3ec1c4df5027561b47e2efe646", size = 79064, upload-time = "2025-11-14T05:08:47.584Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/73/27/c2f24b19dafa197c514abe70eda69bc031c5152c6b1f1e5b20099e2ceedd/testcontainers-4.13.3-py3-none-any.whl", hash = "sha256:063278c4805ffa6dd85e56648a9da3036939e6c0ac1001e851c9276b19b05970", size = 124784, upload-time = "2025-11-14T05:08:46.053Z" }, ] +[[package]] +name = "testcontainers" +version = "4.14.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'emscripten'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "docker", marker = "python_full_version >= '3.10'" }, + { name = "python-dotenv", marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "urllib3", marker = "python_full_version >= '3.10'" }, + { name = "wrapt", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/02/ef62dec9e4f804189c44df23f0b86897c738d38e9c48282fcd410308632f/testcontainers-4.14.1.tar.gz", hash = "sha256:316f1bb178d829c003acd650233e3ff3c59a833a08d8661c074f58a4fbd42a64", size = 80148, upload-time = "2026-01-31T23:13:46.915Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/31/5e7b23f9e43ff7fd46d243808d70c5e8daf3bc08ecf5a7fb84d5e38f7603/testcontainers-4.14.1-py3-none-any.whl", hash = "sha256:03dfef4797b31c82e7b762a454b6afec61a2a512ad54af47ab41e4fa5415f891", size = 125640, upload-time = "2026-01-31T23:13:45.464Z" }, +] + [[package]] name = "tomli" -version = "2.3.0" +version = "2.4.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" }, +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, ] [[package]] name = "ty" -version = "0.0.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/9d/59e955cc39206a0d58df5374808785c45ec2a8a2a230eb1638fbb4fe5c5d/ty-0.0.8.tar.gz", hash = "sha256:352ac93d6e0050763be57ad1e02087f454a842887e618ec14ac2103feac48676", size = 4828477, upload-time = "2025-12-29T13:50:07.193Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/2b/dd61f7e50a69c72f72c625d026e9ab64a0db62b2dd32e7426b520e2429c6/ty-0.0.8-py3-none-linux_armv6l.whl", hash = "sha256:a289d033c5576fa3b4a582b37d63395edf971cdbf70d2d2e6b8c95638d1a4fcd", size = 9853417, upload-time = "2025-12-29T13:50:08.979Z" }, - { url = "https://files.pythonhosted.org/packages/90/72/3f1d3c64a049a388e199de4493689a51fc6aa5ff9884c03dea52b4966657/ty-0.0.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:788ea97dc8153a94e476c4d57b2551a9458f79c187c4aba48fcb81f05372924a", size = 9657890, upload-time = "2025-12-29T13:50:27.867Z" }, - { url = "https://files.pythonhosted.org/packages/71/d1/08ac676bd536de3c2baba0deb60e67b3196683a2fabebfd35659d794b5e9/ty-0.0.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1b5f1f3d3e230f35a29e520be7c3d90194a5229f755b721e9092879c00842d31", size = 9180129, upload-time = "2025-12-29T13:50:22.842Z" }, - { url = "https://files.pythonhosted.org/packages/af/93/610000e2cfeea1875900f73a375ba917624b0a008d4b8a6c18c894c8dbbc/ty-0.0.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6da9ed377fbbcec0a3b60b2ca5fd30496e15068f47cef2344ba87923e78ba996", size = 9683517, upload-time = "2025-12-29T13:50:18.658Z" }, - { url = "https://files.pythonhosted.org/packages/05/04/bef50ba7d8580b0140be597de5cc0ba9a63abe50d3f65560235f23658762/ty-0.0.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7d0a2bdce5e701d19eb8d46d9da0fe31340f079cecb7c438f5ac6897c73fc5ba", size = 9676279, upload-time = "2025-12-29T13:50:25.207Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b9/2aff1ef1f41b25898bc963173ae67fc8f04ca666ac9439a9c4e78d5cc0ff/ty-0.0.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef9078799d26d3cc65366e02392e2b78f64f72911b599e80a8497d2ec3117ddb", size = 10073015, upload-time = "2025-12-29T13:50:35.422Z" }, - { url = "https://files.pythonhosted.org/packages/df/0e/9feb6794b6ff0a157c3e6a8eb6365cbfa3adb9c0f7976e2abdc48615dd72/ty-0.0.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:54814ac39b4ab67cf111fc0a236818155cf49828976152378347a7678d30ee89", size = 10961649, upload-time = "2025-12-29T13:49:58.717Z" }, - { url = "https://files.pythonhosted.org/packages/f4/3b/faf7328b14f00408f4f65c9d01efe52e11b9bcc4a79e06187b370457b004/ty-0.0.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4baf0a80398e8b6c68fa36ff85045a50ede1906cd4edb41fb4fab46d471f1d4", size = 10676190, upload-time = "2025-12-29T13:50:01.11Z" }, - { url = "https://files.pythonhosted.org/packages/64/a5/cfeca780de7eeab7852c911c06a84615a174d23e9ae08aae42a645771094/ty-0.0.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ac8e23c3faefc579686799ef1649af8d158653169ad5c3a7df56b152781eeb67", size = 10438641, upload-time = "2025-12-29T13:50:29.664Z" }, - { url = "https://files.pythonhosted.org/packages/0e/8d/8667c7e0ac9f13c461ded487c8d7350f440cd39ba866d0160a8e1b1efd6c/ty-0.0.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b558a647a073d0c25540aaa10f8947de826cb8757d034dd61ecf50ab8dbd77bf", size = 10214082, upload-time = "2025-12-29T13:50:31.531Z" }, - { url = "https://files.pythonhosted.org/packages/f8/11/e563229870e2c1d089e7e715c6c3b7605a34436dddf6f58e9205823020c2/ty-0.0.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8c0104327bf480508bd81f320e22074477df159d9eff85207df39e9c62ad5e96", size = 9664364, upload-time = "2025-12-29T13:50:05.443Z" }, - { url = "https://files.pythonhosted.org/packages/b1/ad/05b79b778bf5237bcd7ee08763b226130aa8da872cbb151c8cfa2e886203/ty-0.0.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:496f1cb87261dd1a036a5609da80ee13de2e6ee4718a661bfa2afb91352fe528", size = 9679440, upload-time = "2025-12-29T13:50:11.289Z" }, - { url = "https://files.pythonhosted.org/packages/12/b5/23ba887769c4a7b8abfd1b6395947dc3dcc87533fbf86379d3a57f87ae8f/ty-0.0.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2c488031f92a075ae39d13ac6295fdce2141164ec38c5d47aa8dc24ee3afa37e", size = 9808201, upload-time = "2025-12-29T13:50:21.003Z" }, - { url = "https://files.pythonhosted.org/packages/f8/90/5a82ac0a0707db55376922aed80cd5fca6b2e6d6e9bcd8c286e6b43b4084/ty-0.0.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:90d6f08c5982fa3e802b8918a32e326153519077b827f91c66eea4913a86756a", size = 10313262, upload-time = "2025-12-29T13:50:03.306Z" }, - { url = "https://files.pythonhosted.org/packages/14/f7/ff97f37f0a75db9495ddbc47738ec4339837867c4bfa145bdcfbd0d1eb2f/ty-0.0.8-py3-none-win32.whl", hash = "sha256:d7f460ad6fc9325e9cc8ea898949bbd88141b4609d1088d7ede02ce2ef06e776", size = 9254675, upload-time = "2025-12-29T13:50:33.35Z" }, - { url = "https://files.pythonhosted.org/packages/af/51/eba5d83015e04630002209e3590c310a0ff1d26e1815af204a322617a42e/ty-0.0.8-py3-none-win_amd64.whl", hash = "sha256:1641fb8dedc3d2da43279d21c3c7c1f80d84eae5c264a1e8daa544458e433c19", size = 10131382, upload-time = "2025-12-29T13:50:13.719Z" }, - { url = "https://files.pythonhosted.org/packages/38/1c/0d8454ff0f0f258737ecfe84f6e508729191d29663b404832f98fa5626b7/ty-0.0.8-py3-none-win_arm64.whl", hash = "sha256:ec74f022f315bede478ecae1277a01ab618e6500c1d68450d7883f5cd6ed554a", size = 9636374, upload-time = "2025-12-29T13:50:16.344Z" }, +version = "0.0.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/5e/da108b9eeb392e02ff0478a34e9651490b36af295881cb56575b83f0cc3a/ty-0.0.19.tar.gz", hash = "sha256:ee3d9ed4cb586e77f6efe3d0fe5a855673ca438a3d533a27598e1d3502a2948a", size = 5220026, upload-time = "2026-02-26T12:13:15.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/31/fd8c6067abb275bea11523d21ecf64e1d870b1ce80cac529cf6636df1471/ty-0.0.19-py3-none-linux_armv6l.whl", hash = "sha256:29bed05d34c8a7597567b8e327c53c1aed4a07dcfbe6c81e6d60c7444936ad77", size = 10268470, upload-time = "2026-02-26T12:13:42.881Z" }, + { url = "https://files.pythonhosted.org/packages/15/de/16a11bbf7d98c75849fc41f5d008b89bb5d080a4b10dc8ea851ee2bd371b/ty-0.0.19-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:79140870c688c97ec68e723c28935ddef9d91a76d48c68e665fe7c851e628b8a", size = 10098562, upload-time = "2026-02-26T12:13:31.618Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4f/086d6ff6686eadf903913c45b53ab96694b62bbfee1d8cf3e55a9b5aa4b2/ty-0.0.19-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6e9c1f9cfa6a26f7881d14d75cf963af743f6c4189e6aa3e3b4056a65f22e730", size = 9604073, upload-time = "2026-02-26T12:13:24.645Z" }, + { url = "https://files.pythonhosted.org/packages/95/13/888a6b6c7ed4a880fee91bec997f775153ce86215ee4c56b868516314734/ty-0.0.19-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbca43b050edf1db2e64ae7b79add233c2aea2855b8a876081bbd032edcd0610", size = 10106295, upload-time = "2026-02-26T12:13:40.584Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e8/05a372cae8da482de73b8246fb43236bf11e24ac28c879804568108759db/ty-0.0.19-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8acaa88ab1955ca6b15a0ccc274011c4961377fe65c3948e5d2b212f2517b87c", size = 10098234, upload-time = "2026-02-26T12:13:33.725Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f1/5b0958e9e9576e7662192fe689bbb3dc88e631a4e073db3047793a547d58/ty-0.0.19-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a901b6a6dd9d17d5b3b2e7bafc3057294e88da3f5de507347316687d7f191a1", size = 10607218, upload-time = "2026-02-26T12:13:17.576Z" }, + { url = "https://files.pythonhosted.org/packages/fb/ab/358c78b77844f58ff5aca368550ab16c719f1ab0ec892ceb1114d7500f4e/ty-0.0.19-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8deafdaaaee65fd121c66064da74a922d8501be4a2d50049c71eab521a23eff7", size = 11160593, upload-time = "2026-02-26T12:13:36.008Z" }, + { url = "https://files.pythonhosted.org/packages/95/59/827fc346d66a59fe48e9689a5ceb67dbbd5b4de2e8d4625371af39a2e8b7/ty-0.0.19-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:035e56071af280897441018f74f921b97d53aec0856f8af85f4f949df8eda07d", size = 10822392, upload-time = "2026-02-26T12:13:29.415Z" }, + { url = "https://files.pythonhosted.org/packages/81/f9/3bbfbbe35478de9bcd63848f4bc9bffda72278dd9732dbad3efc3978432e/ty-0.0.19-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abdf5885130393ce74501dba792f48ce0a515756ec81c33a4b324bdf3509df6e", size = 10707139, upload-time = "2026-02-26T12:13:20.148Z" }, + { url = "https://files.pythonhosted.org/packages/12/9e/597023b183ec4ade83a36a0cea5c103f3bffa34f70813d46386c61447fb8/ty-0.0.19-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:877e89005c8f9d1dbff5ad14cbac9f35c528406fde38926f9b44f24830de8d6a", size = 10096933, upload-time = "2026-02-26T12:13:45.266Z" }, + { url = "https://files.pythonhosted.org/packages/1e/76/d0d2f6e674db2a17c8efa5e26682b9dfa8d34774705f35902a7b45ebd3bd/ty-0.0.19-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:39bd1da051c1e4d316efaf79dbed313255633f7c6ad6e24d29f4d9c6ffaf4de6", size = 10109547, upload-time = "2026-02-26T12:13:22.17Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b0/76026c06b852a3aa4fdb5bd329fdc2175aaf3c64a3fafece9cc4df167cee/ty-0.0.19-py3-none-musllinux_1_2_i686.whl", hash = "sha256:87df8415a6c9cb27b8f1382fcdc6052e59f5b9f50f78bc14663197eb5c8d3699", size = 10289110, upload-time = "2026-02-26T12:13:38.29Z" }, + { url = "https://files.pythonhosted.org/packages/14/6c/f3b3a189816b4f079b20fe5d0d7ee38e38a472f53cc6770bb6571147e3de/ty-0.0.19-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:89b6bb23c332ed5c38dd859eb5793f887abcc936f681a40d4ea68e35eac1af33", size = 10796479, upload-time = "2026-02-26T12:13:10.992Z" }, + { url = "https://files.pythonhosted.org/packages/3d/18/caee33d1ce9dd50bd94c26cde7cda4f6971e22e474e7d72a5c86d745ad58/ty-0.0.19-py3-none-win32.whl", hash = "sha256:19b33df3aa7af7b1a9eaa4e1175c3b4dec0f5f2e140243e3492c8355c37418f3", size = 9677215, upload-time = "2026-02-26T12:13:08.519Z" }, + { url = "https://files.pythonhosted.org/packages/81/41/18fc0771d0b1da7d7cc2fc9af278d3122b754fe8b521a748734f4e16ecfd/ty-0.0.19-py3-none-win_amd64.whl", hash = "sha256:b9052c61464cdd76bc8e6796f2588c08700f25d0dcbc225bb165e390ea9d96a4", size = 10651252, upload-time = "2026-02-26T12:13:13.035Z" }, + { url = "https://files.pythonhosted.org/packages/8b/8c/26f7ce8863eb54510082747b3dfb1046ba24f16fc11de18c0e5feb36ff18/ty-0.0.19-py3-none-win_arm64.whl", hash = "sha256:9329804b66dcbae8e7af916ef4963221ed53b8ec7d09b0793591c5ae8a0f3270", size = 10093195, upload-time = "2026-02-26T12:13:26.816Z" }, ] [[package]] @@ -1322,7 +1605,16 @@ name = "typing-extensions" version = "4.15.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.10'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'emscripten'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.10.*'", "python_full_version >= '3.9.2' and python_full_version < '3.10'", "python_full_version >= '3.9' and python_full_version < '3.9.2'", ] @@ -1333,160 +1625,111 @@ wheels = [ [[package]] name = "urllib3" -version = "2.2.3" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677, upload-time = "2024-09-12T10:52:18.401Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338, upload-time = "2024-09-12T10:52:16.589Z" }, -] - -[[package]] -name = "urllib3" -version = "2.6.2" +version = "2.6.3" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version >= '3.9.2' and python_full_version < '3.10'", - "python_full_version >= '3.9' and python_full_version < '3.9.2'", -] -sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787d7aa0355ba36a6cadf1768b934c652ea78acbd59dcd/urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797", size = 432930, upload-time = "2025-12-11T15:56:40.252Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" }, + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] [[package]] name = "virtualenv" -version = "20.36.1" +version = "21.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock", version = "3.16.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "filelock", version = "3.19.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "filelock", version = "3.20.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "filelock", version = "3.24.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "platformdirs", version = "4.3.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "platformdirs", version = "4.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "platformdirs", version = "4.9.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "python-discovery" }, { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544e1d0f4de93bddec248499ccf97d4791bc3122c9d4f3/virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba", size = 6032239, upload-time = "2026-01-09T18:21:01.296Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/c9/18d4b36606d6091844daa3bd93cf7dc78e6f5da21d9f21d06c221104b684/virtualenv-21.1.0.tar.gz", hash = "sha256:1990a0188c8f16b6b9cf65c9183049007375b26aad415514d377ccacf1e4fb44", size = 5840471, upload-time = "2026-02-27T08:49:29.702Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" }, + { url = "https://files.pythonhosted.org/packages/78/55/896b06bf93a49bec0f4ae2a6f1ed12bd05c8860744ac3a70eda041064e4d/virtualenv-21.1.0-py3-none-any.whl", hash = "sha256:164f5e14c5587d170cf98e60378eb91ea35bf037be313811905d3a24ea33cc07", size = 5825072, upload-time = "2026-02-27T08:49:27.516Z" }, ] [[package]] name = "wrapt" -version = "2.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/49/2a/6de8a50cb435b7f42c46126cf1a54b2aab81784e74c8595c8e025e8f36d3/wrapt-2.0.1.tar.gz", hash = "sha256:9c9c635e78497cacb81e84f8b11b23e0aacac7a136e73b8e5b2109a1d9fc468f", size = 82040, upload-time = "2025-11-07T00:45:33.312Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/61/0d/12d8c803ed2ce4e5e7d5b9f5f602721f9dfef82c95959f3ce97fa584bb5c/wrapt-2.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:64b103acdaa53b7caf409e8d45d39a8442fe6dcfec6ba3f3d141e0cc2b5b4dbd", size = 77481, upload-time = "2025-11-07T00:43:11.103Z" }, - { url = "https://files.pythonhosted.org/packages/05/3e/4364ebe221ebf2a44d9fc8695a19324692f7dd2795e64bd59090856ebf12/wrapt-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:91bcc576260a274b169c3098e9a3519fb01f2989f6d3d386ef9cbf8653de1374", size = 60692, upload-time = "2025-11-07T00:43:13.697Z" }, - { url = "https://files.pythonhosted.org/packages/1f/ff/ae2a210022b521f86a8ddcdd6058d137c051003812b0388a5e9a03d3fe10/wrapt-2.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ab594f346517010050126fcd822697b25a7031d815bb4fbc238ccbe568216489", size = 61574, upload-time = "2025-11-07T00:43:14.967Z" }, - { url = "https://files.pythonhosted.org/packages/c6/93/5cf92edd99617095592af919cb81d4bff61c5dbbb70d3c92099425a8ec34/wrapt-2.0.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:36982b26f190f4d737f04a492a68accbfc6fa042c3f42326fdfbb6c5b7a20a31", size = 113688, upload-time = "2025-11-07T00:43:18.275Z" }, - { url = "https://files.pythonhosted.org/packages/a0/0a/e38fc0cee1f146c9fb266d8ef96ca39fb14a9eef165383004019aa53f88a/wrapt-2.0.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23097ed8bc4c93b7bf36fa2113c6c733c976316ce0ee2c816f64ca06102034ef", size = 115698, upload-time = "2025-11-07T00:43:19.407Z" }, - { url = "https://files.pythonhosted.org/packages/b0/85/bef44ea018b3925fb0bcbe9112715f665e4d5309bd945191da814c314fd1/wrapt-2.0.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bacfe6e001749a3b64db47bcf0341da757c95959f592823a93931a422395013", size = 112096, upload-time = "2025-11-07T00:43:16.5Z" }, - { url = "https://files.pythonhosted.org/packages/7c/0b/733a2376e413117e497aa1a5b1b78e8f3a28c0e9537d26569f67d724c7c5/wrapt-2.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8ec3303e8a81932171f455f792f8df500fc1a09f20069e5c16bd7049ab4e8e38", size = 114878, upload-time = "2025-11-07T00:43:20.81Z" }, - { url = "https://files.pythonhosted.org/packages/da/03/d81dcb21bbf678fcda656495792b059f9d56677d119ca022169a12542bd0/wrapt-2.0.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:3f373a4ab5dbc528a94334f9fe444395b23c2f5332adab9ff4ea82f5a9e33bc1", size = 111298, upload-time = "2025-11-07T00:43:22.229Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d5/5e623040e8056e1108b787020d56b9be93dbbf083bf2324d42cde80f3a19/wrapt-2.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f49027b0b9503bf6c8cdc297ca55006b80c2f5dd36cecc72c6835ab6e10e8a25", size = 113361, upload-time = "2025-11-07T00:43:24.301Z" }, - { url = "https://files.pythonhosted.org/packages/a1/f3/de535ccecede6960e28c7b722e5744846258111d6c9f071aa7578ea37ad3/wrapt-2.0.1-cp310-cp310-win32.whl", hash = "sha256:8330b42d769965e96e01fa14034b28a2a7600fbf7e8f0cc90ebb36d492c993e4", size = 58035, upload-time = "2025-11-07T00:43:28.96Z" }, - { url = "https://files.pythonhosted.org/packages/21/15/39d3ca5428a70032c2ec8b1f1c9d24c32e497e7ed81aed887a4998905fcc/wrapt-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:1218573502a8235bb8a7ecaed12736213b22dcde9feab115fa2989d42b5ded45", size = 60383, upload-time = "2025-11-07T00:43:25.804Z" }, - { url = "https://files.pythonhosted.org/packages/43/c2/dfd23754b7f7a4dce07e08f4309c4e10a40046a83e9ae1800f2e6b18d7c1/wrapt-2.0.1-cp310-cp310-win_arm64.whl", hash = "sha256:eda8e4ecd662d48c28bb86be9e837c13e45c58b8300e43ba3c9b4fa9900302f7", size = 58894, upload-time = "2025-11-07T00:43:27.074Z" }, - { url = "https://files.pythonhosted.org/packages/98/60/553997acf3939079dab022e37b67b1904b5b0cc235503226898ba573b10c/wrapt-2.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e17283f533a0d24d6e5429a7d11f250a58d28b4ae5186f8f47853e3e70d2590", size = 77480, upload-time = "2025-11-07T00:43:30.573Z" }, - { url = "https://files.pythonhosted.org/packages/2d/50/e5b3d30895d77c52105c6d5cbf94d5b38e2a3dd4a53d22d246670da98f7c/wrapt-2.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:85df8d92158cb8f3965aecc27cf821461bb5f40b450b03facc5d9f0d4d6ddec6", size = 60690, upload-time = "2025-11-07T00:43:31.594Z" }, - { url = "https://files.pythonhosted.org/packages/f0/40/660b2898703e5cbbb43db10cdefcc294274458c3ca4c68637c2b99371507/wrapt-2.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1be685ac7700c966b8610ccc63c3187a72e33cab53526a27b2a285a662cd4f7", size = 61578, upload-time = "2025-11-07T00:43:32.918Z" }, - { url = "https://files.pythonhosted.org/packages/5b/36/825b44c8a10556957bc0c1d84c7b29a40e05fcf1873b6c40aa9dbe0bd972/wrapt-2.0.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:df0b6d3b95932809c5b3fecc18fda0f1e07452d05e2662a0b35548985f256e28", size = 114115, upload-time = "2025-11-07T00:43:35.605Z" }, - { url = "https://files.pythonhosted.org/packages/83/73/0a5d14bb1599677304d3c613a55457d34c344e9b60eda8a737c2ead7619e/wrapt-2.0.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da7384b0e5d4cae05c97cd6f94faaf78cc8b0f791fc63af43436d98c4ab37bb", size = 116157, upload-time = "2025-11-07T00:43:37.058Z" }, - { url = "https://files.pythonhosted.org/packages/01/22/1c158fe763dbf0a119f985d945711d288994fe5514c0646ebe0eb18b016d/wrapt-2.0.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ec65a78fbd9d6f083a15d7613b2800d5663dbb6bb96003899c834beaa68b242c", size = 112535, upload-time = "2025-11-07T00:43:34.138Z" }, - { url = "https://files.pythonhosted.org/packages/5c/28/4f16861af67d6de4eae9927799b559c20ebdd4fe432e89ea7fe6fcd9d709/wrapt-2.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7de3cc939be0e1174969f943f3b44e0d79b6f9a82198133a5b7fc6cc92882f16", size = 115404, upload-time = "2025-11-07T00:43:39.214Z" }, - { url = "https://files.pythonhosted.org/packages/a0/8b/7960122e625fad908f189b59c4aae2d50916eb4098b0fb2819c5a177414f/wrapt-2.0.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:fb1a5b72cbd751813adc02ef01ada0b0d05d3dcbc32976ce189a1279d80ad4a2", size = 111802, upload-time = "2025-11-07T00:43:40.476Z" }, - { url = "https://files.pythonhosted.org/packages/3e/73/7881eee5ac31132a713ab19a22c9e5f1f7365c8b1df50abba5d45b781312/wrapt-2.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3fa272ca34332581e00bf7773e993d4f632594eb2d1b0b162a9038df0fd971dd", size = 113837, upload-time = "2025-11-07T00:43:42.921Z" }, - { url = "https://files.pythonhosted.org/packages/45/00/9499a3d14e636d1f7089339f96c4409bbc7544d0889f12264efa25502ae8/wrapt-2.0.1-cp311-cp311-win32.whl", hash = "sha256:fc007fdf480c77301ab1afdbb6ab22a5deee8885f3b1ed7afcb7e5e84a0e27be", size = 58028, upload-time = "2025-11-07T00:43:47.369Z" }, - { url = "https://files.pythonhosted.org/packages/70/5d/8f3d7eea52f22638748f74b102e38fdf88cb57d08ddeb7827c476a20b01b/wrapt-2.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:47434236c396d04875180171ee1f3815ca1eada05e24a1ee99546320d54d1d1b", size = 60385, upload-time = "2025-11-07T00:43:44.34Z" }, - { url = "https://files.pythonhosted.org/packages/14/e2/32195e57a8209003587bbbad44d5922f13e0ced2a493bb46ca882c5b123d/wrapt-2.0.1-cp311-cp311-win_arm64.whl", hash = "sha256:837e31620e06b16030b1d126ed78e9383815cbac914693f54926d816d35d8edf", size = 58893, upload-time = "2025-11-07T00:43:46.161Z" }, - { url = "https://files.pythonhosted.org/packages/cb/73/8cb252858dc8254baa0ce58ce382858e3a1cf616acebc497cb13374c95c6/wrapt-2.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1fdbb34da15450f2b1d735a0e969c24bdb8d8924892380126e2a293d9902078c", size = 78129, upload-time = "2025-11-07T00:43:48.852Z" }, - { url = "https://files.pythonhosted.org/packages/19/42/44a0db2108526ee6e17a5ab72478061158f34b08b793df251d9fbb9a7eb4/wrapt-2.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3d32794fe940b7000f0519904e247f902f0149edbe6316c710a8562fb6738841", size = 61205, upload-time = "2025-11-07T00:43:50.402Z" }, - { url = "https://files.pythonhosted.org/packages/4d/8a/5b4b1e44b791c22046e90d9b175f9a7581a8cc7a0debbb930f81e6ae8e25/wrapt-2.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:386fb54d9cd903ee0012c09291336469eb7b244f7183d40dc3e86a16a4bace62", size = 61692, upload-time = "2025-11-07T00:43:51.678Z" }, - { url = "https://files.pythonhosted.org/packages/11/53/3e794346c39f462bcf1f58ac0487ff9bdad02f9b6d5ee2dc84c72e0243b2/wrapt-2.0.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7b219cb2182f230676308cdcacd428fa837987b89e4b7c5c9025088b8a6c9faf", size = 121492, upload-time = "2025-11-07T00:43:55.017Z" }, - { url = "https://files.pythonhosted.org/packages/c6/7e/10b7b0e8841e684c8ca76b462a9091c45d62e8f2de9c4b1390b690eadf16/wrapt-2.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:641e94e789b5f6b4822bb8d8ebbdfc10f4e4eae7756d648b717d980f657a9eb9", size = 123064, upload-time = "2025-11-07T00:43:56.323Z" }, - { url = "https://files.pythonhosted.org/packages/0e/d1/3c1e4321fc2f5ee7fd866b2d822aa89b84495f28676fd976c47327c5b6aa/wrapt-2.0.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe21b118b9f58859b5ebaa4b130dee18669df4bd111daad082b7beb8799ad16b", size = 117403, upload-time = "2025-11-07T00:43:53.258Z" }, - { url = "https://files.pythonhosted.org/packages/a4/b0/d2f0a413cf201c8c2466de08414a15420a25aa83f53e647b7255cc2fab5d/wrapt-2.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17fb85fa4abc26a5184d93b3efd2dcc14deb4b09edcdb3535a536ad34f0b4dba", size = 121500, upload-time = "2025-11-07T00:43:57.468Z" }, - { url = "https://files.pythonhosted.org/packages/bd/45/bddb11d28ca39970a41ed48a26d210505120f925918592283369219f83cc/wrapt-2.0.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:b89ef9223d665ab255ae42cc282d27d69704d94be0deffc8b9d919179a609684", size = 116299, upload-time = "2025-11-07T00:43:58.877Z" }, - { url = "https://files.pythonhosted.org/packages/81/af/34ba6dd570ef7a534e7eec0c25e2615c355602c52aba59413411c025a0cb/wrapt-2.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a453257f19c31b31ba593c30d997d6e5be39e3b5ad9148c2af5a7314061c63eb", size = 120622, upload-time = "2025-11-07T00:43:59.962Z" }, - { url = "https://files.pythonhosted.org/packages/e2/3e/693a13b4146646fb03254636f8bafd20c621955d27d65b15de07ab886187/wrapt-2.0.1-cp312-cp312-win32.whl", hash = "sha256:3e271346f01e9c8b1130a6a3b0e11908049fe5be2d365a5f402778049147e7e9", size = 58246, upload-time = "2025-11-07T00:44:03.169Z" }, - { url = "https://files.pythonhosted.org/packages/a7/36/715ec5076f925a6be95f37917b66ebbeaa1372d1862c2ccd7a751574b068/wrapt-2.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:2da620b31a90cdefa9cd0c2b661882329e2e19d1d7b9b920189956b76c564d75", size = 60492, upload-time = "2025-11-07T00:44:01.027Z" }, - { url = "https://files.pythonhosted.org/packages/ef/3e/62451cd7d80f65cc125f2b426b25fbb6c514bf6f7011a0c3904fc8c8df90/wrapt-2.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:aea9c7224c302bc8bfc892b908537f56c430802560e827b75ecbde81b604598b", size = 58987, upload-time = "2025-11-07T00:44:02.095Z" }, - { url = "https://files.pythonhosted.org/packages/ad/fe/41af4c46b5e498c90fc87981ab2972fbd9f0bccda597adb99d3d3441b94b/wrapt-2.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:47b0f8bafe90f7736151f61482c583c86b0693d80f075a58701dd1549b0010a9", size = 78132, upload-time = "2025-11-07T00:44:04.628Z" }, - { url = "https://files.pythonhosted.org/packages/1c/92/d68895a984a5ebbbfb175512b0c0aad872354a4a2484fbd5552e9f275316/wrapt-2.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cbeb0971e13b4bd81d34169ed57a6dda017328d1a22b62fda45e1d21dd06148f", size = 61211, upload-time = "2025-11-07T00:44:05.626Z" }, - { url = "https://files.pythonhosted.org/packages/e8/26/ba83dc5ae7cf5aa2b02364a3d9cf74374b86169906a1f3ade9a2d03cf21c/wrapt-2.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb7cffe572ad0a141a7886a1d2efa5bef0bf7fe021deeea76b3ab334d2c38218", size = 61689, upload-time = "2025-11-07T00:44:06.719Z" }, - { url = "https://files.pythonhosted.org/packages/cf/67/d7a7c276d874e5d26738c22444d466a3a64ed541f6ef35f740dbd865bab4/wrapt-2.0.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8d60527d1ecfc131426b10d93ab5d53e08a09c5fa0175f6b21b3252080c70a9", size = 121502, upload-time = "2025-11-07T00:44:09.557Z" }, - { url = "https://files.pythonhosted.org/packages/0f/6b/806dbf6dd9579556aab22fc92908a876636e250f063f71548a8660382184/wrapt-2.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c654eafb01afac55246053d67a4b9a984a3567c3808bb7df2f8de1c1caba2e1c", size = 123110, upload-time = "2025-11-07T00:44:10.64Z" }, - { url = "https://files.pythonhosted.org/packages/e5/08/cdbb965fbe4c02c5233d185d070cabed2ecc1f1e47662854f95d77613f57/wrapt-2.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98d873ed6c8b4ee2418f7afce666751854d6d03e3c0ec2a399bb039cd2ae89db", size = 117434, upload-time = "2025-11-07T00:44:08.138Z" }, - { url = "https://files.pythonhosted.org/packages/2d/d1/6aae2ce39db4cb5216302fa2e9577ad74424dfbe315bd6669725569e048c/wrapt-2.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9e850f5b7fc67af856ff054c71690d54fa940c3ef74209ad9f935b4f66a0233", size = 121533, upload-time = "2025-11-07T00:44:12.142Z" }, - { url = "https://files.pythonhosted.org/packages/79/35/565abf57559fbe0a9155c29879ff43ce8bd28d2ca61033a3a3dd67b70794/wrapt-2.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e505629359cb5f751e16e30cf3f91a1d3ddb4552480c205947da415d597f7ac2", size = 116324, upload-time = "2025-11-07T00:44:13.28Z" }, - { url = "https://files.pythonhosted.org/packages/e1/e0/53ff5e76587822ee33e560ad55876d858e384158272cd9947abdd4ad42ca/wrapt-2.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2879af909312d0baf35f08edeea918ee3af7ab57c37fe47cb6a373c9f2749c7b", size = 120627, upload-time = "2025-11-07T00:44:14.431Z" }, - { url = "https://files.pythonhosted.org/packages/7c/7b/38df30fd629fbd7612c407643c63e80e1c60bcc982e30ceeae163a9800e7/wrapt-2.0.1-cp313-cp313-win32.whl", hash = "sha256:d67956c676be5a24102c7407a71f4126d30de2a569a1c7871c9f3cabc94225d7", size = 58252, upload-time = "2025-11-07T00:44:17.814Z" }, - { url = "https://files.pythonhosted.org/packages/85/64/d3954e836ea67c4d3ad5285e5c8fd9d362fd0a189a2db622df457b0f4f6a/wrapt-2.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:9ca66b38dd642bf90c59b6738af8070747b610115a39af2498535f62b5cdc1c3", size = 60500, upload-time = "2025-11-07T00:44:15.561Z" }, - { url = "https://files.pythonhosted.org/packages/89/4e/3c8b99ac93527cfab7f116089db120fef16aac96e5f6cdb724ddf286086d/wrapt-2.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:5a4939eae35db6b6cec8e7aa0e833dcca0acad8231672c26c2a9ab7a0f8ac9c8", size = 58993, upload-time = "2025-11-07T00:44:16.65Z" }, - { url = "https://files.pythonhosted.org/packages/f9/f4/eff2b7d711cae20d220780b9300faa05558660afb93f2ff5db61fe725b9a/wrapt-2.0.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a52f93d95c8d38fed0669da2ebdb0b0376e895d84596a976c15a9eb45e3eccb3", size = 82028, upload-time = "2025-11-07T00:44:18.944Z" }, - { url = "https://files.pythonhosted.org/packages/0c/67/cb945563f66fd0f61a999339460d950f4735c69f18f0a87ca586319b1778/wrapt-2.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4e54bbf554ee29fcceee24fa41c4d091398b911da6e7f5d7bffda963c9aed2e1", size = 62949, upload-time = "2025-11-07T00:44:20.074Z" }, - { url = "https://files.pythonhosted.org/packages/ec/ca/f63e177f0bbe1e5cf5e8d9b74a286537cd709724384ff20860f8f6065904/wrapt-2.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:908f8c6c71557f4deaa280f55d0728c3bca0960e8c3dd5ceeeafb3c19942719d", size = 63681, upload-time = "2025-11-07T00:44:21.345Z" }, - { url = "https://files.pythonhosted.org/packages/39/a1/1b88fcd21fd835dca48b556daef750952e917a2794fa20c025489e2e1f0f/wrapt-2.0.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e2f84e9af2060e3904a32cea9bb6db23ce3f91cfd90c6b426757cf7cc01c45c7", size = 152696, upload-time = "2025-11-07T00:44:24.318Z" }, - { url = "https://files.pythonhosted.org/packages/62/1c/d9185500c1960d9f5f77b9c0b890b7fc62282b53af7ad1b6bd779157f714/wrapt-2.0.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3612dc06b436968dfb9142c62e5dfa9eb5924f91120b3c8ff501ad878f90eb3", size = 158859, upload-time = "2025-11-07T00:44:25.494Z" }, - { url = "https://files.pythonhosted.org/packages/91/60/5d796ed0f481ec003220c7878a1d6894652efe089853a208ea0838c13086/wrapt-2.0.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d2d947d266d99a1477cd005b23cbd09465276e302515e122df56bb9511aca1b", size = 146068, upload-time = "2025-11-07T00:44:22.81Z" }, - { url = "https://files.pythonhosted.org/packages/04/f8/75282dd72f102ddbfba137e1e15ecba47b40acff32c08ae97edbf53f469e/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7d539241e87b650cbc4c3ac9f32c8d1ac8a54e510f6dca3f6ab60dcfd48c9b10", size = 155724, upload-time = "2025-11-07T00:44:26.634Z" }, - { url = "https://files.pythonhosted.org/packages/5a/27/fe39c51d1b344caebb4a6a9372157bdb8d25b194b3561b52c8ffc40ac7d1/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:4811e15d88ee62dbf5c77f2c3ff3932b1e3ac92323ba3912f51fc4016ce81ecf", size = 144413, upload-time = "2025-11-07T00:44:27.939Z" }, - { url = "https://files.pythonhosted.org/packages/83/2b/9f6b643fe39d4505c7bf926d7c2595b7cb4b607c8c6b500e56c6b36ac238/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c1c91405fcf1d501fa5d55df21e58ea49e6b879ae829f1039faaf7e5e509b41e", size = 150325, upload-time = "2025-11-07T00:44:29.29Z" }, - { url = "https://files.pythonhosted.org/packages/bb/b6/20ffcf2558596a7f58a2e69c89597128781f0b88e124bf5a4cadc05b8139/wrapt-2.0.1-cp313-cp313t-win32.whl", hash = "sha256:e76e3f91f864e89db8b8d2a8311d57df93f01ad6bb1e9b9976d1f2e83e18315c", size = 59943, upload-time = "2025-11-07T00:44:33.211Z" }, - { url = "https://files.pythonhosted.org/packages/87/6a/0e56111cbb3320151eed5d3821ee1373be13e05b376ea0870711f18810c3/wrapt-2.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:83ce30937f0ba0d28818807b303a412440c4b63e39d3d8fc036a94764b728c92", size = 63240, upload-time = "2025-11-07T00:44:30.935Z" }, - { url = "https://files.pythonhosted.org/packages/1d/54/5ab4c53ea1f7f7e5c3e7c1095db92932cc32fd62359d285486d00c2884c3/wrapt-2.0.1-cp313-cp313t-win_arm64.whl", hash = "sha256:4b55cacc57e1dc2d0991dbe74c6419ffd415fb66474a02335cb10efd1aa3f84f", size = 60416, upload-time = "2025-11-07T00:44:32.002Z" }, - { url = "https://files.pythonhosted.org/packages/73/81/d08d83c102709258e7730d3cd25befd114c60e43ef3891d7e6877971c514/wrapt-2.0.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:5e53b428f65ece6d9dad23cb87e64506392b720a0b45076c05354d27a13351a1", size = 78290, upload-time = "2025-11-07T00:44:34.691Z" }, - { url = "https://files.pythonhosted.org/packages/f6/14/393afba2abb65677f313aa680ff0981e829626fed39b6a7e3ec807487790/wrapt-2.0.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ad3ee9d0f254851c71780966eb417ef8e72117155cff04821ab9b60549694a55", size = 61255, upload-time = "2025-11-07T00:44:35.762Z" }, - { url = "https://files.pythonhosted.org/packages/c4/10/a4a1f2fba205a9462e36e708ba37e5ac95f4987a0f1f8fd23f0bf1fc3b0f/wrapt-2.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7b822c61ed04ee6ad64bc90d13368ad6eb094db54883b5dde2182f67a7f22c0", size = 61797, upload-time = "2025-11-07T00:44:37.22Z" }, - { url = "https://files.pythonhosted.org/packages/12/db/99ba5c37cf1c4fad35349174f1e38bd8d992340afc1ff27f526729b98986/wrapt-2.0.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7164a55f5e83a9a0b031d3ffab4d4e36bbec42e7025db560f225489fa929e509", size = 120470, upload-time = "2025-11-07T00:44:39.425Z" }, - { url = "https://files.pythonhosted.org/packages/30/3f/a1c8d2411eb826d695fc3395a431757331582907a0ec59afce8fe8712473/wrapt-2.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e60690ba71a57424c8d9ff28f8d006b7ad7772c22a4af432188572cd7fa004a1", size = 122851, upload-time = "2025-11-07T00:44:40.582Z" }, - { url = "https://files.pythonhosted.org/packages/b3/8d/72c74a63f201768d6a04a8845c7976f86be6f5ff4d74996c272cefc8dafc/wrapt-2.0.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3cd1a4bd9a7a619922a8557e1318232e7269b5fb69d4ba97b04d20450a6bf970", size = 117433, upload-time = "2025-11-07T00:44:38.313Z" }, - { url = "https://files.pythonhosted.org/packages/c7/5a/df37cf4042cb13b08256f8e27023e2f9b3d471d553376616591bb99bcb31/wrapt-2.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b4c2e3d777e38e913b8ce3a6257af72fb608f86a1df471cb1d4339755d0a807c", size = 121280, upload-time = "2025-11-07T00:44:41.69Z" }, - { url = "https://files.pythonhosted.org/packages/54/34/40d6bc89349f9931e1186ceb3e5fbd61d307fef814f09fbbac98ada6a0c8/wrapt-2.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3d366aa598d69416b5afedf1faa539fac40c1d80a42f6b236c88c73a3c8f2d41", size = 116343, upload-time = "2025-11-07T00:44:43.013Z" }, - { url = "https://files.pythonhosted.org/packages/70/66/81c3461adece09d20781dee17c2366fdf0cb8754738b521d221ca056d596/wrapt-2.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c235095d6d090aa903f1db61f892fffb779c1eaeb2a50e566b52001f7a0f66ed", size = 119650, upload-time = "2025-11-07T00:44:44.523Z" }, - { url = "https://files.pythonhosted.org/packages/46/3a/d0146db8be8761a9e388cc9cc1c312b36d583950ec91696f19bbbb44af5a/wrapt-2.0.1-cp314-cp314-win32.whl", hash = "sha256:bfb5539005259f8127ea9c885bdc231978c06b7a980e63a8a61c8c4c979719d0", size = 58701, upload-time = "2025-11-07T00:44:48.277Z" }, - { url = "https://files.pythonhosted.org/packages/1a/38/5359da9af7d64554be63e9046164bd4d8ff289a2dd365677d25ba3342c08/wrapt-2.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:4ae879acc449caa9ed43fc36ba08392b9412ee67941748d31d94e3cedb36628c", size = 60947, upload-time = "2025-11-07T00:44:46.086Z" }, - { url = "https://files.pythonhosted.org/packages/aa/3f/96db0619276a833842bf36343685fa04f987dd6e3037f314531a1e00492b/wrapt-2.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:8639b843c9efd84675f1e100ed9e99538ebea7297b62c4b45a7042edb84db03e", size = 59359, upload-time = "2025-11-07T00:44:47.164Z" }, - { url = "https://files.pythonhosted.org/packages/71/49/5f5d1e867bf2064bf3933bc6cf36ade23505f3902390e175e392173d36a2/wrapt-2.0.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:9219a1d946a9b32bb23ccae66bdb61e35c62773ce7ca6509ceea70f344656b7b", size = 82031, upload-time = "2025-11-07T00:44:49.4Z" }, - { url = "https://files.pythonhosted.org/packages/2b/89/0009a218d88db66ceb83921e5685e820e2c61b59bbbb1324ba65342668bc/wrapt-2.0.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fa4184e74197af3adad3c889a1af95b53bb0466bced92ea99a0c014e48323eec", size = 62952, upload-time = "2025-11-07T00:44:50.74Z" }, - { url = "https://files.pythonhosted.org/packages/ae/18/9b968e920dd05d6e44bcc918a046d02afea0fb31b2f1c80ee4020f377cbe/wrapt-2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c5ef2f2b8a53b7caee2f797ef166a390fef73979b15778a4a153e4b5fedce8fa", size = 63688, upload-time = "2025-11-07T00:44:52.248Z" }, - { url = "https://files.pythonhosted.org/packages/a6/7d/78bdcb75826725885d9ea26c49a03071b10c4c92da93edda612910f150e4/wrapt-2.0.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e042d653a4745be832d5aa190ff80ee4f02c34b21f4b785745eceacd0907b815", size = 152706, upload-time = "2025-11-07T00:44:54.613Z" }, - { url = "https://files.pythonhosted.org/packages/dd/77/cac1d46f47d32084a703df0d2d29d47e7eb2a7d19fa5cbca0e529ef57659/wrapt-2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2afa23318136709c4b23d87d543b425c399887b4057936cd20386d5b1422b6fa", size = 158866, upload-time = "2025-11-07T00:44:55.79Z" }, - { url = "https://files.pythonhosted.org/packages/8a/11/b521406daa2421508903bf8d5e8b929216ec2af04839db31c0a2c525eee0/wrapt-2.0.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c72328f668cf4c503ffcf9434c2b71fdd624345ced7941bc6693e61bbe36bef", size = 146148, upload-time = "2025-11-07T00:44:53.388Z" }, - { url = "https://files.pythonhosted.org/packages/0c/c0/340b272bed297baa7c9ce0c98ef7017d9c035a17a6a71dce3184b8382da2/wrapt-2.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3793ac154afb0e5b45d1233cb94d354ef7a983708cc3bb12563853b1d8d53747", size = 155737, upload-time = "2025-11-07T00:44:56.971Z" }, - { url = "https://files.pythonhosted.org/packages/f3/93/bfcb1fb2bdf186e9c2883a4d1ab45ab099c79cbf8f4e70ea453811fa3ea7/wrapt-2.0.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fec0d993ecba3991645b4857837277469c8cc4c554a7e24d064d1ca291cfb81f", size = 144451, upload-time = "2025-11-07T00:44:58.515Z" }, - { url = "https://files.pythonhosted.org/packages/d2/6b/dca504fb18d971139d232652656180e3bd57120e1193d9a5899c3c0b7cdd/wrapt-2.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:949520bccc1fa227274da7d03bf238be15389cd94e32e4297b92337df9b7a349", size = 150353, upload-time = "2025-11-07T00:44:59.753Z" }, - { url = "https://files.pythonhosted.org/packages/1d/f6/a1de4bd3653afdf91d250ca5c721ee51195df2b61a4603d4b373aa804d1d/wrapt-2.0.1-cp314-cp314t-win32.whl", hash = "sha256:be9e84e91d6497ba62594158d3d31ec0486c60055c49179edc51ee43d095f79c", size = 60609, upload-time = "2025-11-07T00:45:03.315Z" }, - { url = "https://files.pythonhosted.org/packages/01/3a/07cd60a9d26fe73efead61c7830af975dfdba8537632d410462672e4432b/wrapt-2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:61c4956171c7434634401db448371277d07032a81cc21c599c22953374781395", size = 64038, upload-time = "2025-11-07T00:45:00.948Z" }, - { url = "https://files.pythonhosted.org/packages/41/99/8a06b8e17dddbf321325ae4eb12465804120f699cd1b8a355718300c62da/wrapt-2.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:35cdbd478607036fee40273be8ed54a451f5f23121bd9d4be515158f9498f7ad", size = 60634, upload-time = "2025-11-07T00:45:02.087Z" }, - { url = "https://files.pythonhosted.org/packages/4d/26/ed6979672ebe0e33f6059fdc8182c4c536e575b6f03d349a542082ca03fb/wrapt-2.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:90897ea1cf0679763b62e79657958cd54eae5659f6360fc7d2ccc6f906342183", size = 77192, upload-time = "2025-11-07T00:45:04.493Z" }, - { url = "https://files.pythonhosted.org/packages/b5/a5/fb0974e8d21ef17f75ffa365b395c04eefa23eb6e45548e94c781e93c306/wrapt-2.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:50844efc8cdf63b2d90cd3d62d4947a28311e6266ce5235a219d21b195b4ec2c", size = 60475, upload-time = "2025-11-07T00:45:05.671Z" }, - { url = "https://files.pythonhosted.org/packages/6b/7b/56bf38c8bd5e8a48749f1a13c743eddcbd7a616da342b4877f79ec3e7087/wrapt-2.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:49989061a9977a8cbd6d20f2efa813f24bf657c6990a42967019ce779a878dbf", size = 61311, upload-time = "2025-11-07T00:45:06.822Z" }, - { url = "https://files.pythonhosted.org/packages/18/70/ba94af50f2145cb431163d74d405083beb16782818b20c956138e4f59299/wrapt-2.0.1-cp38-cp38-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:09c7476ab884b74dce081ad9bfd07fe5822d8600abade571cb1f66d5fc915af6", size = 118542, upload-time = "2025-11-07T00:45:08.324Z" }, - { url = "https://files.pythonhosted.org/packages/14/ac/537c8f9cec8a422cfed45b28665ea33344928fd67913e5ff98af0c11470c/wrapt-2.0.1-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1a8a09a004ef100e614beec82862d11fc17d601092c3599afd22b1f36e4137e", size = 120989, upload-time = "2025-11-07T00:45:09.928Z" }, - { url = "https://files.pythonhosted.org/packages/7f/b8/463284d8a74e56c88f5f2fb9b572178a294e0beb945b8ee2a7ca43a1696d/wrapt-2.0.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:89a82053b193837bf93c0f8a57ded6e4b6d88033a499dadff5067e912c2a41e9", size = 118937, upload-time = "2025-11-07T00:45:11.157Z" }, - { url = "https://files.pythonhosted.org/packages/3c/8e/08b8f9de6b3cfd269504b345d31679d283e50cc93cb0521a44475bb7311b/wrapt-2.0.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:f26f8e2ca19564e2e1fdbb6a0e47f36e0efbab1acc31e15471fad88f828c75f6", size = 117150, upload-time = "2025-11-07T00:45:12.324Z" }, - { url = "https://files.pythonhosted.org/packages/4c/f3/0eab878bb4d0eadbec2b75e399cfa6aa802e634587756d59419080aae1f5/wrapt-2.0.1-cp38-cp38-win32.whl", hash = "sha256:115cae4beed3542e37866469a8a1f2b9ec549b4463572b000611e9946b86e6f6", size = 57936, upload-time = "2025-11-07T00:45:15.468Z" }, - { url = "https://files.pythonhosted.org/packages/03/e5/fc964b370bf568312deda176682138ccbd41960285a7de49002183e2aa08/wrapt-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:c4012a2bd37059d04f8209916aa771dfb564cccb86079072bdcd48a308b6a5c5", size = 60308, upload-time = "2025-11-07T00:45:13.573Z" }, - { url = "https://files.pythonhosted.org/packages/c6/1f/5af0ae22368ec69067a577f9e07a0dd2619a1f63aabc2851263679942667/wrapt-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:68424221a2dc00d634b54f92441914929c5ffb1c30b3b837343978343a3512a3", size = 77478, upload-time = "2025-11-07T00:45:16.65Z" }, - { url = "https://files.pythonhosted.org/packages/8c/b7/fd6b563aada859baabc55db6aa71b8afb4a3ceb8bc33d1053e4c7b5e0109/wrapt-2.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6bd1a18f5a797fe740cb3d7a0e853a8ce6461cc62023b630caec80171a6b8097", size = 60687, upload-time = "2025-11-07T00:45:17.896Z" }, - { url = "https://files.pythonhosted.org/packages/0f/8c/9ededfff478af396bcd081076986904bdca336d9664d247094150c877dcb/wrapt-2.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fb3a86e703868561c5cad155a15c36c716e1ab513b7065bd2ac8ed353c503333", size = 61563, upload-time = "2025-11-07T00:45:19.109Z" }, - { url = "https://files.pythonhosted.org/packages/ab/a7/d795a1aa2b6ab20ca21157fe03cbfc6aa7e870a88ac3b4ea189e2f6c79f0/wrapt-2.0.1-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5dc1b852337c6792aa111ca8becff5bacf576bf4a0255b0f05eb749da6a1643e", size = 113395, upload-time = "2025-11-07T00:45:21.551Z" }, - { url = "https://files.pythonhosted.org/packages/61/32/56cde2bbf95f2d5698a1850a765520aa86bc7ae0f95b8ec80b6f2e2049bb/wrapt-2.0.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c046781d422f0830de6329fa4b16796096f28a92c8aef3850674442cdcb87b7f", size = 115362, upload-time = "2025-11-07T00:45:22.809Z" }, - { url = "https://files.pythonhosted.org/packages/cf/53/8d3cc433847c219212c133a3e8305bd087b386ef44442ff39189e8fa62ac/wrapt-2.0.1-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f73f9f7a0ebd0db139253d27e5fc8d2866ceaeef19c30ab5d69dcbe35e1a6981", size = 111766, upload-time = "2025-11-07T00:45:20.294Z" }, - { url = "https://files.pythonhosted.org/packages/b8/d3/14b50c2d0463c0dcef8f388cb1527ed7bbdf0972b9fd9976905f36c77ebf/wrapt-2.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:b667189cf8efe008f55bbda321890bef628a67ab4147ebf90d182f2dadc78790", size = 114560, upload-time = "2025-11-07T00:45:24.054Z" }, - { url = "https://files.pythonhosted.org/packages/3a/b8/4f731ff178f77ae55385586de9ff4b4261e872cf2ced4875e6c976fbcb8b/wrapt-2.0.1-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:a9a83618c4f0757557c077ef71d708ddd9847ed66b7cc63416632af70d3e2308", size = 110999, upload-time = "2025-11-07T00:45:25.596Z" }, - { url = "https://files.pythonhosted.org/packages/fe/bb/5f1bb0f9ae9d12e19f1d71993d052082062603e83fe3e978377f918f054d/wrapt-2.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1e9b121e9aeb15df416c2c960b8255a49d44b4038016ee17af03975992d03931", size = 113164, upload-time = "2025-11-07T00:45:26.8Z" }, - { url = "https://files.pythonhosted.org/packages/ad/f6/f3a3c623d3065c7bf292ee0b73566236b562d5ed894891bd8e435762b618/wrapt-2.0.1-cp39-cp39-win32.whl", hash = "sha256:1f186e26ea0a55f809f232e92cc8556a0977e00183c3ebda039a807a42be1494", size = 58028, upload-time = "2025-11-07T00:45:30.943Z" }, - { url = "https://files.pythonhosted.org/packages/24/78/647c609dfa18063a7fcd5c23f762dd006be401cc9206314d29c9b0b12078/wrapt-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:bf4cb76f36be5de950ce13e22e7fdf462b35b04665a12b64f3ac5c1bbbcf3728", size = 60380, upload-time = "2025-11-07T00:45:28.341Z" }, - { url = "https://files.pythonhosted.org/packages/07/90/0c14b241d18d80ddf4c847a5f52071e126e8a6a9e5a8a7952add8ef0d766/wrapt-2.0.1-cp39-cp39-win_arm64.whl", hash = "sha256:d6cc985b9c8b235bd933990cdbf0f891f8e010b65a3911f7a55179cd7b0fc57b", size = 58895, upload-time = "2025-11-07T00:45:29.527Z" }, - { url = "https://files.pythonhosted.org/packages/15/d1/b51471c11592ff9c012bd3e2f7334a6ff2f42a7aed2caffcf0bdddc9cb89/wrapt-2.0.1-py3-none-any.whl", hash = "sha256:4d2ce1bf1a48c5277d7969259232b57645aae5686dba1eaeade39442277afbca", size = 44046, upload-time = "2025-11-07T00:45:32.116Z" }, +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/37/ae31f40bec90de2f88d9597d0b5281e23ffe85b893a47ca5d9c05c63a4f6/wrapt-2.1.1.tar.gz", hash = "sha256:5fdcb09bf6db023d88f312bd0767594b414655d58090fc1c46b3414415f67fac", size = 81329, upload-time = "2026-02-03T02:12:13.786Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/21/293b657a27accfbbbb6007ebd78af0efa2083dac83e8f523272ea09b4638/wrapt-2.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7e927375e43fd5a985b27a8992327c22541b6dede1362fc79df337d26e23604f", size = 60554, upload-time = "2026-02-03T02:11:17.362Z" }, + { url = "https://files.pythonhosted.org/packages/25/e9/96dd77728b54a899d4ce2798d7b1296989ce687ed3c0cb917d6b3154bf5d/wrapt-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c99544b6a7d40ca22195563b6d8bc3986ee8bb82f272f31f0670fe9440c869", size = 61496, upload-time = "2026-02-03T02:12:54.732Z" }, + { url = "https://files.pythonhosted.org/packages/44/79/4c755b45df6ef30c0dd628ecfaa0c808854be147ca438429da70a162833c/wrapt-2.1.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b2be3fa5f4efaf16ee7c77d0556abca35f5a18ad4ac06f0ef3904c3399010ce9", size = 113528, upload-time = "2026-02-03T02:12:26.405Z" }, + { url = "https://files.pythonhosted.org/packages/9f/63/23ce28f7b841217d9a6337a340fbb8d4a7fbd67a89d47f377c8550fa34aa/wrapt-2.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67c90c1ae6489a6cb1a82058902caa8006706f7b4e8ff766f943e9d2c8e608d0", size = 115536, upload-time = "2026-02-03T02:11:54.397Z" }, + { url = "https://files.pythonhosted.org/packages/23/7b/5ca8d3b12768670d16c8329e29960eedd56212770365a02a8de8bf73dc01/wrapt-2.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:05c0db35ccffd7480143e62df1e829d101c7b86944ae3be7e4869a7efa621f53", size = 114716, upload-time = "2026-02-03T02:12:20.771Z" }, + { url = "https://files.pythonhosted.org/packages/c7/3a/9789ccb14a096d30bb847bf3ee137bf682cc9750c2ce155f4c5ae1962abf/wrapt-2.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0c2ec9f616755b2e1e0bf4d0961f59bb5c2e7a77407e7e2c38ef4f7d2fdde12c", size = 113200, upload-time = "2026-02-03T02:12:07.688Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e5/4ec3526ce6ce920b267c8d35d2c2f0874d3fad2744c8b7259353f1132baa/wrapt-2.1.1-cp310-cp310-win32.whl", hash = "sha256:203ba6b3f89e410e27dbd30ff7dccaf54dcf30fda0b22aa1b82d560c7f9fe9a1", size = 57876, upload-time = "2026-02-03T02:11:42.61Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4e/661c7c76ecd85375b2bc03488941a3a1078642af481db24949e2b9de01f4/wrapt-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:6f9426d9cfc2f8732922fc96198052e55c09bb9db3ddaa4323a18e055807410e", size = 60224, upload-time = "2026-02-03T02:11:19.096Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b7/53c7252d371efada4cb119e72e774fa2c6b3011fc33e3e552cdf48fb9488/wrapt-2.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:69c26f51b67076b40714cff81bdd5826c0b10c077fb6b0678393a6a2f952a5fc", size = 58645, upload-time = "2026-02-03T02:12:10.396Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a8/9254e4da74b30a105935197015b18b31b7a298bf046e67d8952ef74967bd/wrapt-2.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6c366434a7fb914c7a5de508ed735ef9c133367114e1a7cb91dfb5cd806a1549", size = 60554, upload-time = "2026-02-03T02:11:13.038Z" }, + { url = "https://files.pythonhosted.org/packages/9e/a1/378579880cc7af226354054a2c255f69615b379d8adad482bfe2f22a0dc2/wrapt-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5d6a2068bd2e1e19e5a317c8c0b288267eec4e7347c36bc68a6e378a39f19ee7", size = 61491, upload-time = "2026-02-03T02:12:56.077Z" }, + { url = "https://files.pythonhosted.org/packages/dc/72/957b51c56acca35701665878ad31626182199fc4afecfe67dea072210f95/wrapt-2.1.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:891ab4713419217b2aed7dd106c9200f64e6a82226775a0d2ebd6bef2ebd1747", size = 113949, upload-time = "2026-02-03T02:11:04.516Z" }, + { url = "https://files.pythonhosted.org/packages/cd/74/36bbebb4a3d2ae9c3e6929639721f8606cd0710a82a777c371aa69e36504/wrapt-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8ef36a0df38d2dc9d907f6617f89e113c5892e0a35f58f45f75901af0ce7d81", size = 115989, upload-time = "2026-02-03T02:12:19.398Z" }, + { url = "https://files.pythonhosted.org/packages/ae/0d/f1177245a083c7be284bc90bddfe5aece32cdd5b858049cb69ce001a0e8d/wrapt-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:76e9af3ebd86f19973143d4d592cbf3e970cf3f66ddee30b16278c26ae34b8ab", size = 115242, upload-time = "2026-02-03T02:11:08.111Z" }, + { url = "https://files.pythonhosted.org/packages/62/3e/3b7cf5da27e59df61b1eae2d07dd03ff5d6f75b5408d694873cca7a8e33c/wrapt-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ff562067485ebdeaef2fa3fe9b1876bc4e7b73762e0a01406ad81e2076edcebf", size = 113676, upload-time = "2026-02-03T02:12:41.026Z" }, + { url = "https://files.pythonhosted.org/packages/f7/65/8248d3912c705f2c66f81cb97c77436f37abcbedb16d633b5ab0d795d8cd/wrapt-2.1.1-cp311-cp311-win32.whl", hash = "sha256:9e60a30aa0909435ec4ea2a3c53e8e1b50ac9f640c0e9fe3f21fd248a22f06c5", size = 57863, upload-time = "2026-02-03T02:12:18.112Z" }, + { url = "https://files.pythonhosted.org/packages/6b/31/d29310ab335f71f00c50466153b3dc985aaf4a9fc03263e543e136859541/wrapt-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:7d79954f51fcf84e5ec4878ab4aea32610d70145c5bbc84b3370eabfb1e096c2", size = 60224, upload-time = "2026-02-03T02:12:29.289Z" }, + { url = "https://files.pythonhosted.org/packages/0c/90/a6ec319affa6e2894962a0cb9d73c67f88af1a726d15314bfb5c88b8a08d/wrapt-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:d3ffc6b0efe79e08fd947605fd598515aebefe45e50432dc3b5cd437df8b1ada", size = 58643, upload-time = "2026-02-03T02:12:43.022Z" }, + { url = "https://files.pythonhosted.org/packages/df/cb/4d5255d19bbd12be7f8ee2c1fb4269dddec9cef777ef17174d357468efaa/wrapt-2.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab8e3793b239db021a18782a5823fcdea63b9fe75d0e340957f5828ef55fcc02", size = 61143, upload-time = "2026-02-03T02:11:46.313Z" }, + { url = "https://files.pythonhosted.org/packages/6f/07/7ed02daa35542023464e3c8b7cb937fa61f6c61c0361ecf8f5fecf8ad8da/wrapt-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7c0300007836373d1c2df105b40777986accb738053a92fe09b615a7a4547e9f", size = 61740, upload-time = "2026-02-03T02:12:51.966Z" }, + { url = "https://files.pythonhosted.org/packages/c4/60/a237a4e4a36f6d966061ccc9b017627d448161b19e0a3ab80a7c7c97f859/wrapt-2.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2b27c070fd1132ab23957bcd4ee3ba707a91e653a9268dc1afbd39b77b2799f7", size = 121327, upload-time = "2026-02-03T02:11:06.796Z" }, + { url = "https://files.pythonhosted.org/packages/ae/fe/9139058a3daa8818fc67e6460a2340e8bbcf3aef8b15d0301338bbe181ca/wrapt-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b0e36d845e8b6f50949b6b65fc6cd279f47a1944582ed4ec8258cd136d89a64", size = 122903, upload-time = "2026-02-03T02:12:48.657Z" }, + { url = "https://files.pythonhosted.org/packages/91/10/b8479202b4164649675846a531763531f0a6608339558b5a0a718fc49a8d/wrapt-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4aeea04a9889370fcfb1ef828c4cc583f36a875061505cd6cd9ba24d8b43cc36", size = 121333, upload-time = "2026-02-03T02:11:32.148Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/75fc793b791d79444aca2c03ccde64e8b99eda321b003f267d570b7b0985/wrapt-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d88b46bb0dce9f74b6817bc1758ff2125e1ca9e1377d62ea35b6896142ab6825", size = 120458, upload-time = "2026-02-03T02:11:16.039Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8f/c3f30d511082ca6d947c405f9d8f6c8eaf83cfde527c439ec2c9a30eb5ea/wrapt-2.1.1-cp312-cp312-win32.whl", hash = "sha256:63decff76ca685b5c557082dfbea865f3f5f6d45766a89bff8dc61d336348833", size = 58086, upload-time = "2026-02-03T02:12:35.041Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c8/37625b643eea2849f10c3b90f69c7462faa4134448d4443234adaf122ae5/wrapt-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:b828235d26c1e35aca4107039802ae4b1411be0fe0367dd5b7e4d90e562fcbcd", size = 60328, upload-time = "2026-02-03T02:12:45.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/79/56242f07572d5682ba8065a9d4d9c2218313f576e3c3471873c2a5355ffd/wrapt-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:75128507413a9f1bcbe2db88fd18fbdbf80f264b82fa33a6996cdeaf01c52352", size = 58722, upload-time = "2026-02-03T02:12:27.949Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ca/3cf290212855b19af9fcc41b725b5620b32f470d6aad970c2593500817eb/wrapt-2.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ce9646e17fa7c3e2e7a87e696c7de66512c2b4f789a8db95c613588985a2e139", size = 61150, upload-time = "2026-02-03T02:12:50.575Z" }, + { url = "https://files.pythonhosted.org/packages/9d/33/5b8f89a82a9859ce82da4870c799ad11ce15648b6e1c820fec3e23f4a19f/wrapt-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:428cfc801925454395aa468ba7ddb3ed63dc0d881df7b81626cdd433b4e2b11b", size = 61743, upload-time = "2026-02-03T02:11:55.733Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2f/60c51304fbdf47ce992d9eefa61fbd2c0e64feee60aaa439baf42ea6f40b/wrapt-2.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5797f65e4d58065a49088c3b32af5410751cd485e83ba89e5a45e2aa8905af98", size = 121341, upload-time = "2026-02-03T02:11:20.461Z" }, + { url = "https://files.pythonhosted.org/packages/ad/03/ce5256e66dd94e521ad5e753c78185c01b6eddbed3147be541f4d38c0cb7/wrapt-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a2db44a71202c5ae4bb5f27c6d3afbc5b23053f2e7e78aa29704541b5dad789", size = 122947, upload-time = "2026-02-03T02:11:33.596Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ae/50ca8854b81b946a11a36fcd6ead32336e6db2c14b6e4a8b092b80741178/wrapt-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8d5350c3590af09c1703dd60ec78a7370c0186e11eaafb9dda025a30eee6492d", size = 121370, upload-time = "2026-02-03T02:11:09.886Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d9/d6a7c654e0043319b4cc137a4caaf7aa16b46b51ee8df98d1060254705b7/wrapt-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d9b076411bed964e752c01b49fd224cc385f3a96f520c797d38412d70d08359", size = 120465, upload-time = "2026-02-03T02:11:37.592Z" }, + { url = "https://files.pythonhosted.org/packages/55/90/65be41e40845d951f714b5a77e84f377a3787b1e8eee6555a680da6d0db5/wrapt-2.1.1-cp313-cp313-win32.whl", hash = "sha256:0bb7207130ce6486727baa85373503bf3334cc28016f6928a0fa7e19d7ecdc06", size = 58090, upload-time = "2026-02-03T02:12:53.342Z" }, + { url = "https://files.pythonhosted.org/packages/5f/66/6a09e0294c4fc8c26028a03a15191721c9271672467cc33e6617ee0d91d2/wrapt-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:cbfee35c711046b15147b0ae7db9b976f01c9520e6636d992cd9e69e5e2b03b1", size = 60341, upload-time = "2026-02-03T02:12:36.384Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f0/20ceb8b701e9a71555c87a5ddecbed76ec16742cf1e4b87bbaf26735f998/wrapt-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:7d2756061022aebbf57ba14af9c16e8044e055c22d38de7bf40d92b565ecd2b0", size = 58731, upload-time = "2026-02-03T02:12:01.328Z" }, + { url = "https://files.pythonhosted.org/packages/80/b4/fe95beb8946700b3db371f6ce25115217e7075ca063663b8cca2888ba55c/wrapt-2.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4814a3e58bc6971e46baa910ecee69699110a2bf06c201e24277c65115a20c20", size = 62969, upload-time = "2026-02-03T02:11:51.245Z" }, + { url = "https://files.pythonhosted.org/packages/b8/89/477b0bdc784e3299edf69c279697372b8bd4c31d9c6966eae405442899df/wrapt-2.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:106c5123232ab9b9f4903692e1fa0bdc231510098f04c13c3081f8ad71c3d612", size = 63606, upload-time = "2026-02-03T02:12:02.64Z" }, + { url = "https://files.pythonhosted.org/packages/ed/55/9d0c1269ab76de87715b3b905df54dd25d55bbffd0b98696893eb613469f/wrapt-2.1.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1a40b83ff2535e6e56f190aff123821eea89a24c589f7af33413b9c19eb2c738", size = 152536, upload-time = "2026-02-03T02:11:24.492Z" }, + { url = "https://files.pythonhosted.org/packages/44/18/2004766030462f79ad86efaa62000b5e39b1ff001dcce86650e1625f40ae/wrapt-2.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:789cea26e740d71cf1882e3a42bb29052bc4ada15770c90072cb47bf73fb3dbf", size = 158697, upload-time = "2026-02-03T02:12:32.214Z" }, + { url = "https://files.pythonhosted.org/packages/e1/bb/0a880fa0f35e94ee843df4ee4dd52a699c9263f36881311cfb412c09c3e5/wrapt-2.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ba49c14222d5e5c0ee394495a8655e991dc06cbca5398153aefa5ac08cd6ccd7", size = 155563, upload-time = "2026-02-03T02:11:49.737Z" }, + { url = "https://files.pythonhosted.org/packages/42/ff/cd1b7c4846c8678fac359a6eb975dc7ab5bd606030adb22acc8b4a9f53f1/wrapt-2.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ac8cda531fe55be838a17c62c806824472bb962b3afa47ecbd59b27b78496f4e", size = 150161, upload-time = "2026-02-03T02:12:33.613Z" }, + { url = "https://files.pythonhosted.org/packages/38/ec/67c90a7082f452964b4621e4890e9a490f1add23cdeb7483cc1706743291/wrapt-2.1.1-cp313-cp313t-win32.whl", hash = "sha256:b8af75fe20d381dd5bcc9db2e86a86d7fcfbf615383a7147b85da97c1182225b", size = 59783, upload-time = "2026-02-03T02:11:39.863Z" }, + { url = "https://files.pythonhosted.org/packages/ec/08/466afe4855847d8febdfa2c57c87e991fc5820afbdef01a273683dfd15a0/wrapt-2.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:45c5631c9b6c792b78be2d7352129f776dd72c605be2c3a4e9be346be8376d83", size = 63082, upload-time = "2026-02-03T02:12:09.075Z" }, + { url = "https://files.pythonhosted.org/packages/9a/62/60b629463c28b15b1eeadb3a0691e17568622b12aa5bfa7ebe9b514bfbeb/wrapt-2.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:da815b9263947ac98d088b6414ac83507809a1d385e4632d9489867228d6d81c", size = 60251, upload-time = "2026-02-03T02:11:21.794Z" }, + { url = "https://files.pythonhosted.org/packages/95/a0/1c2396e272f91efe6b16a6a8bce7ad53856c8f9ae4f34ceaa711d63ec9e1/wrapt-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aa1765054245bb01a37f615503290d4e207e3fd59226e78341afb587e9c1236", size = 61311, upload-time = "2026-02-03T02:12:44.41Z" }, + { url = "https://files.pythonhosted.org/packages/b0/9a/d2faba7e61072a7507b5722db63562fdb22f5a24e237d460d18755627f15/wrapt-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:feff14b63a6d86c1eee33a57f77573649f2550935981625be7ff3cb7342efe05", size = 61805, upload-time = "2026-02-03T02:11:59.905Z" }, + { url = "https://files.pythonhosted.org/packages/db/56/073989deb4b5d7d6e7ea424476a4ae4bda02140f2dbeaafb14ba4864dd60/wrapt-2.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:81fc5f22d5fcfdbabde96bb3f5379b9f4476d05c6d524d7259dc5dfb501d3281", size = 120308, upload-time = "2026-02-03T02:12:04.46Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b6/84f37261295e38167a29eb82affaf1dc15948dc416925fe2091beee8e4ac/wrapt-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:951b228ecf66def855d22e006ab9a1fc12535111ae7db2ec576c728f8ddb39e8", size = 122688, upload-time = "2026-02-03T02:11:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/ea/80/32db2eec6671f80c65b7ff175be61bc73d7f5223f6910b0c921bbc4bd11c/wrapt-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ddf582a95641b9a8c8bd643e83f34ecbbfe1b68bc3850093605e469ab680ae3", size = 121115, upload-time = "2026-02-03T02:12:39.068Z" }, + { url = "https://files.pythonhosted.org/packages/49/ef/dcd00383df0cd696614127902153bf067971a5aabcd3c9dcb2d8ef354b2a/wrapt-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fc5c500966bf48913f795f1984704e6d452ba2414207b15e1f8c339a059d5b16", size = 119484, upload-time = "2026-02-03T02:11:48.419Z" }, + { url = "https://files.pythonhosted.org/packages/76/29/0630280cdd2bd8f86f35cb6854abee1c9d6d1a28a0c6b6417cd15d378325/wrapt-2.1.1-cp314-cp314-win32.whl", hash = "sha256:4aa4baadb1f94b71151b8e44a0c044f6af37396c3b8bcd474b78b49e2130a23b", size = 58514, upload-time = "2026-02-03T02:11:58.616Z" }, + { url = "https://files.pythonhosted.org/packages/db/19/5bed84f9089ed2065f6aeda5dfc4f043743f642bc871454b261c3d7d322b/wrapt-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:860e9d3fd81816a9f4e40812f28be4439ab01f260603c749d14be3c0a1170d19", size = 60763, upload-time = "2026-02-03T02:12:24.553Z" }, + { url = "https://files.pythonhosted.org/packages/e4/cb/b967f2f9669e4249b4fe82e630d2a01bc6b9e362b9b12ed91bbe23ae8df4/wrapt-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3c59e103017a2c1ea0ddf589cbefd63f91081d7ce9d491d69ff2512bb1157e23", size = 59051, upload-time = "2026-02-03T02:11:29.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/19/6fed62be29f97eb8a56aff236c3f960a4b4a86e8379dc7046a8005901a97/wrapt-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9fa7c7e1bee9278fc4f5dd8275bc8d25493281a8ec6c61959e37cc46acf02007", size = 63059, upload-time = "2026-02-03T02:12:06.368Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1c/b757fd0adb53d91547ed8fad76ba14a5932d83dde4c994846a2804596378/wrapt-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:39c35e12e8215628984248bd9c8897ce0a474be2a773db207eb93414219d8469", size = 63618, upload-time = "2026-02-03T02:12:23.197Z" }, + { url = "https://files.pythonhosted.org/packages/10/fe/e5ae17b1480957c7988d991b93df9f2425fc51f128cf88144d6a18d0eb12/wrapt-2.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:94ded4540cac9125eaa8ddf5f651a7ec0da6f5b9f248fe0347b597098f8ec14c", size = 152544, upload-time = "2026-02-03T02:11:43.915Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cc/99aed210c6b547b8a6e4cb9d1425e4466727158a6aeb833aa7997e9e08dd/wrapt-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da0af328373f97ed9bdfea24549ac1b944096a5a71b30e41c9b8b53ab3eec04a", size = 158700, upload-time = "2026-02-03T02:12:30.684Z" }, + { url = "https://files.pythonhosted.org/packages/81/0e/d442f745f4957944d5f8ad38bc3a96620bfff3562533b87e486e979f3d99/wrapt-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4ad839b55f0bf235f8e337ce060572d7a06592592f600f3a3029168e838469d3", size = 155561, upload-time = "2026-02-03T02:11:28.164Z" }, + { url = "https://files.pythonhosted.org/packages/51/ac/9891816280e0018c48f8dfd61b136af7b0dcb4a088895db2531acde5631b/wrapt-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0d89c49356e5e2a50fa86b40e0510082abcd0530f926cbd71cf25bee6b9d82d7", size = 150188, upload-time = "2026-02-03T02:11:57.053Z" }, + { url = "https://files.pythonhosted.org/packages/24/98/e2f273b6d70d41f98d0739aa9a269d0b633684a5fb17b9229709375748d4/wrapt-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:f4c7dd22cf7f36aafe772f3d88656559205c3af1b7900adfccb70edeb0d2abc4", size = 60425, upload-time = "2026-02-03T02:11:35.007Z" }, + { url = "https://files.pythonhosted.org/packages/1e/06/b500bfc38a4f82d89f34a13069e748c82c5430d365d9e6b75afb3ab74457/wrapt-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f76bc12c583ab01e73ba0ea585465a41e48d968f6d1311b4daec4f8654e356e3", size = 63855, upload-time = "2026-02-03T02:12:15.47Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cc/5f6193c32166faee1d2a613f278608e6f3b95b96589d020f0088459c46c9/wrapt-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7ea74fc0bec172f1ae5f3505b6655c541786a5cabe4bbc0d9723a56ac32eb9b9", size = 60443, upload-time = "2026-02-03T02:11:30.869Z" }, + { url = "https://files.pythonhosted.org/packages/08/3e/144e085a4a237b60a1b41f56e8a173e5e4f21f42a201e43f8d38272b4772/wrapt-2.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9e03b3d486eb39f5d3f562839f59094dcee30c4039359ea15768dc2214d9e07c", size = 60552, upload-time = "2026-02-03T02:11:41.2Z" }, + { url = "https://files.pythonhosted.org/packages/69/25/576fa5d1e8c0b2657ed411b947bb50c7cc56a0a882fbd1b04574803e668a/wrapt-2.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0fdf3073f488ce4d929929b7799e3b8c52b220c9eb3f4a5a51e2dc0e8ff07881", size = 61498, upload-time = "2026-02-03T02:11:26.425Z" }, + { url = "https://files.pythonhosted.org/packages/48/01/37def21f806dee9db8c12f99b872b3cdf15215bafe3919c982968134b804/wrapt-2.1.1-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0cb4f59238c6625fae2eeb72278da31c9cfba0ff4d9cbe37446b73caa0e9bcf7", size = 113232, upload-time = "2026-02-03T02:11:52.542Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ee/31dfda37ae75db11cc46634aa950c3497f7a8f987d811388bf1b11fe2f80/wrapt-2.1.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f794a1c148871b714cb566f5466ec8288e0148a1c417550983864b3981737cd", size = 115198, upload-time = "2026-02-03T02:12:47.185Z" }, + { url = "https://files.pythonhosted.org/packages/93/d5/43cb27a2d7142bdbe9700099e7261fdc264f63c6b60a8025dd5f8af157da/wrapt-2.1.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:95ef3866631c6da9ce1fc0f1e17b90c4c0aa6d041fc70a11bc90733aee122e1a", size = 114400, upload-time = "2026-02-03T02:12:11.736Z" }, + { url = "https://files.pythonhosted.org/packages/61/91/8429803605df5540b918fe6fc9ffc4f167270f4b7ca1f82eaf7d7b1204b6/wrapt-2.1.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:66bc1b2446f01cbbd3c56b79a3a8435bcd4178ac4e06b091913f7751a7f528b8", size = 112998, upload-time = "2026-02-03T02:11:36.233Z" }, + { url = "https://files.pythonhosted.org/packages/7e/6a/25cb316f3e8262a1626da71b2c299ae2be02fb0547028eac9aa21daeedda/wrapt-2.1.1-cp39-cp39-win32.whl", hash = "sha256:1b9e08e57cabc32972f7c956d10e85093c5da9019faa24faf411e7dd258e528c", size = 57871, upload-time = "2026-02-03T02:12:16.8Z" }, + { url = "https://files.pythonhosted.org/packages/09/69/ffd41e6149ac4bd9700552659842383f44eb96f00e03c2db433bc856bf2f/wrapt-2.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:e75ad48c3cca739f580b5e14c052993eb644c7fa5b4c90aa51193280b30875ae", size = 60222, upload-time = "2026-02-03T02:12:37.727Z" }, + { url = "https://files.pythonhosted.org/packages/59/f0/1889e68a0d389d2552b9e014ed6471addcfab98f09611bac61a8d8fab223/wrapt-2.1.1-cp39-cp39-win_arm64.whl", hash = "sha256:9ccd657873b7f964711447d004563a2bc08d1476d7a1afcad310f3713e6f50f4", size = 58647, upload-time = "2026-02-03T02:11:11.236Z" }, + { url = "https://files.pythonhosted.org/packages/c4/da/5a086bf4c22a41995312db104ec2ffeee2cf6accca9faaee5315c790377d/wrapt-2.1.1-py3-none-any.whl", hash = "sha256:3b0f4629eb954394a3d7c7a1c8cca25f0b07cefe6aa8545e862e9778152de5b7", size = 43886, upload-time = "2026-02-03T02:11:45.048Z" }, ]