From d0d0acd138dfb5eddd1d3539190f82c86bc60014 Mon Sep 17 00:00:00 2001 From: Mohammed-Saajid Date: Sun, 26 Oct 2025 19:14:10 +0530 Subject: [PATCH 01/30] Changed Type Annotations for Validation Error --- fastapi_assets/core/exceptions.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/fastapi_assets/core/exceptions.py b/fastapi_assets/core/exceptions.py index 9029383..47bde2e 100644 --- a/fastapi_assets/core/exceptions.py +++ b/fastapi_assets/core/exceptions.py @@ -1,16 +1,14 @@ """Module for custom exceptions.""" -from typing import Optional - class ValidationError(Exception): """Exception raised for validation errors in FastAPI Assets. Attributes: detail (str): Description of the validation error. - status_code (Optional[int]): HTTP status code associated with the error. + status_code (int): HTTP status code associated with the error. """ - def __init__(self, detail: str, status_code: Optional[int] = None): + def __init__(self, detail: str = "Validation Error", status_code: int = 400): self.detail = detail self.status_code = status_code From 66364aeb9a69876e317d0b40a8f96b3bab9605b2 Mon Sep 17 00:00:00 2001 From: Mohammed-Saajid Date: Sun, 26 Oct 2025 20:07:17 +0530 Subject: [PATCH 02/30] Minor Bug Fixes and Docstring updates --- fastapi_assets/core/base_validator.py | 32 ++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/fastapi_assets/core/base_validator.py b/fastapi_assets/core/base_validator.py index 1ee7f40..1824dbb 100644 --- a/fastapi_assets/core/base_validator.py +++ b/fastapi_assets/core/base_validator.py @@ -7,7 +7,7 @@ class BaseValidator(abc.ABC): - """ + r""" Abstract base class for creating reusable FastAPI validation dependencies. This class provides a standardized `__init__` for handling custom error @@ -15,6 +15,28 @@ class BaseValidator(abc.ABC): `_raise_error`, for subclasses to raise consistent `HTTPException`s. Subclasses MUST implement the `__call__` method. + + .. code-block:: python + from fastapi import Header + from fastapi_assets.core.base_validator import BaseValidator, ValidationError + class MyValidator(BaseValidator): + def _validate_logic(self, token: str) -> None: + # This method is testable without FastAPI + if not token.startswith("sk_"): + # Raise the logic-level exception + raise ValidationError(detail="Token must start with 'sk_'.") + def __call__(self, x_token: str = Header(...)): + try: + # 1. Run the pure validation logic + self._validate_logic(x_token) + except ValidationError as e: + # 2. Catch logic error and raise HTTP error + self._raise_error( + detail=e.detail, # Pass specific detail + status_code=e.status_code # Pass specific code + ) + # 3. Return the valid value + return x_token """ def __init__( @@ -38,7 +60,7 @@ def __init__( def _raise_error( self, - value: Any, + value: Optional[Any] = None, status_code: Optional[int] = None, detail: Optional[Union[str, Callable[[Any], str]]] = None, ) -> None: @@ -48,11 +70,11 @@ def _raise_error( It automatically resolves callable error details. Args: - value: The value that failed validation. This is passed + value (Optional[Any]): The value that failed validation. This is passed to the error_detail callable, if it is one. - status_code: A specific status code for this failure, + status_code (Optional[int]): A specific status code for this failure, overriding the instance's default status_code. - detail: A specific error detail for this failure, + detail (Optional[Union[str, Callable[[Any], str]]]): A specific error detail for this failure, overriding the instance's default error_detail. """ final_status_code = status_code if status_code is not None else self._status_code From 516eaf0c8490d5d29c78eb318389ae8227f960c4 Mon Sep 17 00:00:00 2001 From: Mohammed-Saajid Date: Sun, 26 Oct 2025 20:09:10 +0530 Subject: [PATCH 03/30] Implemented File Validator --- fastapi_assets/validators/file_validator.py | 185 ++++++++++ fastapi_assets/validators/utils.py | 122 +++++++ tests/test_file_validator.py | 382 ++++++++++++++++++++ 3 files changed, 689 insertions(+) create mode 100644 fastapi_assets/validators/file_validator.py create mode 100644 fastapi_assets/validators/utils.py create mode 100644 tests/test_file_validator.py diff --git a/fastapi_assets/validators/file_validator.py b/fastapi_assets/validators/file_validator.py new file mode 100644 index 0000000..3e57c60 --- /dev/null +++ b/fastapi_assets/validators/file_validator.py @@ -0,0 +1,185 @@ +"""Module providing the FileValidator for validating uploaded files in FastAPI.""" +import re +from typing import Any, Callable, List, Optional, Union +from fastapi_assets.core.base_validator import BaseValidator, ValidationError +from fastapi import File, UploadFile +from starlette.datastructures import UploadFile as StarletteUploadFile +from fastapi_assets.validators.utils import ( + _check_size_bounds, + _parse_size_to_bytes, + _match_content_type, + _get_streamed_size, +) + +# FileValidator Implementation + +_SIZE_PATTERN = re.compile(r"(\d+(?:\.\d+)?)\s*([KMGT]?B)", re.IGNORECASE) +_SIZE_UNITS = { + "B": 1, + "KB": 1024, + "MB": 1024**2, + "GB": 1024**3, + "TB": 1024**4, +} + + +class FileValidator(BaseValidator): + r""" + A general-purpose dependency for validating `UploadFile` objects. + + It efficiently checks file size (using `Content-Length` or streaming), + MIME type, and filename. + + .. code-block:: python + from fastapi import FastAPI, UploadFile + from fastapi_assets.validators.file_validator import FileValidator + + app = FastAPI() + + file_validator = FileValidator( + max_size="10MB", + min_size="1KB", + content_types=["image/*", "application/pdf"], + filename_pattern=r"^[\w,\s-]+\.[A-Za-z]{3,4}$", + on_size_error_detail="File size is not within the allowed range.", + on_type_error_detail="Unsupported file type uploaded.", + on_filename_error_detail="Filename does not match the required pattern." + ) + + @app.post("/upload/") + async def upload_file(file: UploadFile = file_validator): + return {"filename": file.filename} + """ + + _DEFAULT_CHUNK_SIZE = 65_536 # 64KB + + def __init__( + self, + *, + max_size: Optional[Union[str, int]] = None, + min_size: Optional[Union[str, int]] = None, + content_types: Optional[List[str]] = None, + filename_pattern: Optional[str] = None, + on_size_error_detail: Optional[Union[str, Callable[[Any], str]]] = None, + on_type_error_detail: Optional[Union[str, Callable[[Any], str]]] = None, + on_filename_error_detail: Optional[Union[str, Callable[[Any], str]]] = None, + ): + """ + Initializes the FileValidator. + + Args: + max_size: Maximum allowed file size (e.g., "10MB", 1024). + min_size: Minimum allowed file size (e.g., "1KB"). + content_types: A list of allowed MIME types (e.g., ["image/jpeg", "image/*"]). + filename_pattern: A regex pattern to validate the filename. + on_size_error_detail: Custom error message for size validation failures. + on_type_error_detail: Custom error message for content-type failures. + on_filename_error_detail: Custom error message for filename pattern failures. + """ + # Call super() with a generic default, which will be overridden + # by the specific error handlers. + super().__init__(status_code=400, error_detail="File validation failed.") + + # Parse sizes once + self._max_size = ( + _parse_size_to_bytes(max_size, size_pattern=_SIZE_PATTERN, size_units=_SIZE_UNITS) + if max_size + else None + ) + self._min_size = ( + _parse_size_to_bytes(min_size, size_pattern=_SIZE_PATTERN, size_units=_SIZE_UNITS) + if min_size + else None + ) + + # Store other validation rules + self._content_types = content_types + self._filename_regex = re.compile(filename_pattern) if filename_pattern else None + + # Store specific error details + self._size_error_detail = on_size_error_detail + self._type_error_detail = on_type_error_detail + self._filename_error_detail = on_filename_error_detail + + async def __call__(self, file: UploadFile = File(...), **kwargs: Any) -> StarletteUploadFile: + """ + FastAPI dependency entry point for file validation. + Args: + file: The uploaded file to validate. + Returns: + The validated UploadFile object. + Raises: + HTTPException: If validation fails. + """ + try: + self._validate_content_type(file) + self._validate_filename(file) + await self._validate_size(file) + # Additional validations can be added here + except ValidationError as e: + # Our custom validation exception, convert to HTTPException + self._raise_error(status_code=e.status_code, detail=str(e.detail)) + except Exception as e: + # Catch any other unexpected error during validation + await file.close() + print("Raising HTTPException for unexpected error:", e) + self._raise_error( + status_code=400, + detail="An unexpected error occurred during file validation.", + ) + + # CRITICAL: Rewind the file stream after reading it so that + # the endpoint can read it from the beginning. + await file.seek(0) + return file + + def _validate_content_type(self, file: UploadFile) -> None: + """Checks the file's MIME type. + Args: + file: The uploaded file to validate. + Returns: + None + Raises: + ValidationError: If the content type is not allowed. + """ + if not self._content_types: + return # No validation rule set + + file_type = file.content_type + if file_type is None or not _match_content_type(file_type, self._content_types): + detail = self._type_error_detail or ( + f"File has an unsupported media type: '{file_type}'. " + f"Allowed types are: {', '.join(self._content_types)}" + ) + print("Raising ValidationError for content type:", detail) + # Use 415 for Unsupported Media Type + raise ValidationError(detail=str(detail), status_code=415) + + def _validate_filename(self, file: UploadFile) -> None: + """Checks the file's name against a regex pattern.""" + if not self._filename_regex: + return # No validation rule set + + if not file.filename or not self._filename_regex.search(file.filename): + detail = self._filename_error_detail or ( + f"Filename '{file.filename}' does not match the required pattern." + ) + raise ValidationError(detail=str(detail), status_code=400) + + async def _validate_size(self, file: UploadFile) -> None: + """ + Checks file size, using Content-Length if available, + or streaming and counting if not. + """ + if self._max_size is None and self._min_size is None: + return # No validation rule set + + file_size: Optional[int] = file.size + + if file_size is not None: + # Easy path: Content-Length was provided + _check_size_bounds(self, file_size, _SIZE_UNITS) + else: + # Hard path: Stream the file to count its size + actual_size = await _get_streamed_size(self, file, _SIZE_UNITS) + _check_size_bounds(self, actual_size, _SIZE_UNITS) diff --git a/fastapi_assets/validators/utils.py b/fastapi_assets/validators/utils.py new file mode 100644 index 0000000..3e4778f --- /dev/null +++ b/fastapi_assets/validators/utils.py @@ -0,0 +1,122 @@ +"""Utility functions for file validation in FastAPI Assets.""" +from typing import TYPE_CHECKING, List, Optional, Union +import re +import fnmatch +from fastapi_assets.core.base_validator import ValidationError +from starlette.datastructures import UploadFile + +if TYPE_CHECKING: + from fastapi_assets.validators.file_validator import FileValidator + + +def _parse_size_to_bytes(size: Union[str, int], size_pattern: re.Pattern, size_units: dict) -> int: + """Converts a size string (e.g., "10MB") to bytes. + Args: + size: Size as a string with units or an integer in bytes. + size_pattern: Compiled regex pattern to parse size strings. + size_units: Dictionary mapping size units to their byte values. + Returns: + Size in bytes as an integer. + """ + if isinstance(size, int): + return size + + match = size_pattern.fullmatch(size.strip()) + if not match: + raise ValueError(f"Invalid size string format: '{size}'") + + value, unit = match.groups() + unit = unit.upper() + + if unit not in size_units: + raise ValueError(f"Invalid size unit: '{unit}' (use B, KB, MB, GB, TB)") + + return int(float(value) * size_units[unit]) + + +def _match_content_type(file_type: str, allowed_types: List[str]) -> bool: + """Checks if a file's MIME type matches any allowed type, supporting wildcards. + Args: + file_type: The MIME type of the file (e.g., "image/jpeg"). + allowed_types: List of allowed MIME types (e.g., ["image/*", "application/pdf"]). + Returns: + True if there's a match, False otherwise. + """ + if not file_type: + return False + for allowed_type in allowed_types: + if fnmatch.fnmatch(file_type, allowed_type): + return True + return False + + +def _format_bytes(byte_count: int, size_units: dict) -> str: + """Utility to format bytes into a human-readable string. + Args: + byte_count: The size in bytes. + size_units: Dictionary mapping size units to their byte values. + Returns: + Formatted size string (e.g., "10MB"). + """ + for unit, limit in reversed(size_units.items()): + if byte_count >= limit: + value = byte_count / limit + return f"{value:.1f}{unit}" if value % 1 else f"{value:.0f}{unit}" + return "0B" + + +async def _get_streamed_size(self: "FileValidator", file: UploadFile, size_units: dict) -> int: + """ + Reads the file in chunks to determine its size, + while enforcing max_size limit during the stream. + Args: + self: The FileValidator instance. + file: The uploaded file to measure. + size_units: Dictionary mapping size units to their byte values. + Returns: + The size of the file in bytes. + Raises: + ValidationError: If the file exceeds max_size during streaming. + """ + actual_size = 0 + async for chunk in file.chunks(self._DEFAULT_CHUNK_SIZE): # type: ignore[attr-defined] + actual_size += len(chunk) + + if self._max_size is not None and actual_size > self._max_size: + # Stop reading and close the file immediately + await file.close() + detail = self._size_error_detail or ( + f"File size exceeds the maximum limit of " + f"{_format_bytes(byte_count=self._max_size, size_units=size_units)} (streaming check)." + ) + raise ValidationError(detail=str(detail), status_code=413) + + return actual_size + + +def _check_size_bounds(self: "FileValidator", file_size: int, size_units: dict) -> None: + """Compares a known file size against max/min bounds. + Args: + self: The FileValidator instance. + file_size: The size of the file in bytes. + size_units: Dictionary mapping size units to their byte values. + Returns: + None + Raises: + ValidationError: If size is out of bounds. + """ + + if self._max_size is not None and file_size > self._max_size: + detail = self._size_error_detail or ( + f"File size ({_format_bytes(byte_count=file_size, size_units=size_units)}) exceeds " + f"the maximum limit of {_format_bytes(byte_count=self._max_size, size_units=size_units)}." + ) + # Use 413 for Payload Too Large + raise ValidationError(detail=str(detail), status_code=413) + + if self._min_size is not None and file_size < self._min_size: + detail = self._size_error_detail or ( + f"File size ({_format_bytes(byte_count=file_size, size_units=size_units)}) is less " + f"than the minimum requirement of {_format_bytes(byte_count=self._min_size, size_units=size_units)}." + ) + raise ValidationError(detail=str(detail), status_code=400) diff --git a/tests/test_file_validator.py b/tests/test_file_validator.py new file mode 100644 index 0000000..a64b36f --- /dev/null +++ b/tests/test_file_validator.py @@ -0,0 +1,382 @@ +""" +Tests for the FileValidator class. +""" + +import pytest +from unittest.mock import MagicMock, AsyncMock, PropertyMock +from fastapi import HTTPException, UploadFile +from fastapi_assets.core.exceptions import ValidationError +from fastapi_assets.validators.file_validator import FileValidator + + +@pytest.fixture +def mock_upload_file() -> MagicMock: + """ + Returns a configurable mock of a FastAPI/Starlette UploadFile. + Uses AsyncMock for async methods. + """ + file = MagicMock(spec=UploadFile) + file.read = AsyncMock() + file.seek = AsyncMock() + file.close = AsyncMock() + + # Set default valid properties + file.filename = "test_file.txt" + file.content_type = "text/plain" + file.size = 1024 # 1KB + + # Default streaming mock (1024 bytes total) + file.read.side_effect = [ + b"a" * 512, + b"b" * 512, + b"", # End of file marker + ] + + # Mock the chunks() async generator + async def mock_chunks(size: int): + """Mock async generator for chunks""" + yield b"a" * 512 + yield b"b" * 512 + + file.chunks = mock_chunks + + return file + + +# --- Test Cases --- + + +@pytest.mark.asyncio +class TestFileValidatorInit: + """Tests for the FileValidator's __init__ method.""" + + def test_init_defaults(self): + """Tests that all rules are None by default.""" + validator = FileValidator() + assert validator._max_size is None + assert validator._min_size is None + assert validator._content_types is None + assert validator._filename_regex is None + + def test_init_size_parsing(self): + """Tests that size strings are correctly parsed to bytes.""" + validator = FileValidator(max_size="2MB", min_size="1KB") + assert validator._max_size == 2 * 1024 * 1024 + assert validator._min_size == 1024 + + def test_init_int_size(self): + """Tests that integer sizes are used directly.""" + validator = FileValidator(max_size=5000) + assert validator._max_size == 5000 + + def test_init_invalid_size_string(self): + """Tests that a malformed size string raises a ValueError.""" + with pytest.raises(ValueError, match="Invalid size string"): + FileValidator(max_size="10 ZB") # ZB not in _SIZE_UNITS + + def test_init_filename_pattern(self): + """Tests that the filename pattern is compiled to regex.""" + pattern = r"\.txt$" + validator = FileValidator(filename_pattern=pattern) + assert validator._filename_regex is not None + assert validator._filename_regex.pattern == pattern + assert validator._filename_regex.search("file.txt") + + def test_init_custom_error_details(self): + """Tests that custom error detail messages are stored.""" + size_err = "File is too big" + type_err = "Wrong file type" + name_err = "Bad name" + validator = FileValidator( + on_size_error_detail=size_err, + on_type_error_detail=type_err, + on_filename_error_detail=name_err, + ) + assert validator._size_error_detail == size_err + assert validator._type_error_detail == type_err + assert validator._filename_error_detail == name_err + + +@pytest.mark.asyncio +class TestFileValidatorCall: + """Tests the main __call__ entry point.""" + + async def test_call_valid_file(self, mock_upload_file: MagicMock): + """ + Tests the happy path where the file is valid and no exceptions are raised. + """ + validator = FileValidator( + max_size="2MB", content_types=["text/plain"], filename_pattern=r"\.txt$" + ) + + # Configure file to be valid + mock_upload_file.size = 1024 * 1024 # 1MB + mock_upload_file.content_type = "text/plain" + mock_upload_file.filename = "report.txt" + + result_file = await validator(mock_upload_file) + + # Should return the file + assert result_file == mock_upload_file + # CRITICAL: Should rewind the file for the endpoint to read + mock_upload_file.seek.assert_called_once_with(0) + + async def test_call_invalid_content_type(self, mock_upload_file: MagicMock): + """Tests that a content-type failure raises a 415 HTTPException.""" + validator = FileValidator(content_types=["image/jpeg"]) + mock_upload_file.content_type = "text/plain" # Invalid + + with pytest.raises(HTTPException) as exc_info: + await validator(mock_upload_file) + print(exc_info) + assert exc_info.value.status_code == 415 + assert "unsupported media type" in exc_info.value.detail + + async def test_call_invalid_filename(self, mock_upload_file: MagicMock): + """Tests that a filename failure raises a 400 HTTPException.""" + validator = FileValidator(filename_pattern=r"\.jpg$") + mock_upload_file.filename = "image.png" # Invalid + + with pytest.raises(HTTPException) as exc_info: + await validator(mock_upload_file) + + assert exc_info.value.status_code == 400 + assert "does not match" in exc_info.value.detail + + async def test_call_invalid_size_too_large(self, mock_upload_file: MagicMock): + """Tests that a size (max) failure raises a 413 HTTPException.""" + validator = FileValidator(max_size="1KB") + mock_upload_file.size = 2000 # Invalid (approx 2KB) + + with pytest.raises(HTTPException) as exc_info: + await validator(mock_upload_file) + + assert exc_info.value.status_code == 413 + assert "exceeds the maximum limit" in exc_info.value.detail + + async def test_call_invalid_size_too_small(self, mock_upload_file: MagicMock): + """Tests that a size (min) failure raises a 400 HTTPException.""" + validator = FileValidator(min_size="5KB") + mock_upload_file.size = 1024 # Invalid (1KB) + + with pytest.raises(HTTPException) as exc_info: + await validator(mock_upload_file) + + assert exc_info.value.status_code == 400 + assert "less than the minimum requirement" in exc_info.value.detail + + async def test_call_streaming_size_too_large(self, mock_upload_file: MagicMock): + """Tests size failure when file.size is None and streaming is required.""" + validator = FileValidator(max_size="1KB") # 1024 bytes + + # Mock file.size = None + mock_upload_file.size = None + + # Mock chunks to return 1500 bytes total + async def mock_chunks_too_large(size: int): + """Mock async generator that yields too much data""" + yield b"a" * 1000 + yield b"b" * 500 + + mock_upload_file.chunks = mock_chunks_too_large + + with pytest.raises(HTTPException) as exc_info: + await validator(mock_upload_file) + + assert exc_info.value.status_code == 413 + assert "exceeds" in exc_info.value.detail.lower() + + async def test_call_unexpected_error(self, mock_upload_file: MagicMock): + """ + Tests that a non-ValidationError exception is caught, the file is closed, + and a 400 error is raised. + """ + # Enable content_types validation so it accesses the content_type property + validator = FileValidator(content_types=["text/plain"]) + + # Force an unexpected error by making content_type raise an exception + type(mock_upload_file).content_type = PropertyMock( + side_effect=Exception("Unexpected crash!") + ) + + with pytest.raises(HTTPException) as exc_info: + await validator(mock_upload_file) + + assert exc_info.value.status_code == 400 + assert "An unexpected error" in exc_info.value.detail + # Should close the file on unexpected error + mock_upload_file.close.assert_called_once() + # Should NOT seek the file + mock_upload_file.seek.assert_not_called() + + +@pytest.mark.asyncio +class TestFileValidatorLogic: + """ + Unit tests for the individual _validate_* logic methods. + These test that the methods raise ValidationError correctly. + """ + + def test_content_type_no_rule(self, mock_upload_file: MagicMock): + """Tests that no rule set passes validation.""" + validator = FileValidator() + try: + validator._validate_content_type(mock_upload_file) + except ValidationError: + pytest.fail("Validation failed when no rule was set") + + def test_content_type_exact_match(self, mock_upload_file: MagicMock): + validator = FileValidator(content_types=["image/png"]) + mock_upload_file.content_type = "image/png" + try: + validator._validate_content_type(mock_upload_file) + except ValidationError: + pytest.fail("Validation failed on exact match") + + def test_content_type_wildcard_match(self, mock_upload_file: MagicMock): + validator = FileValidator(content_types=["image/*"]) + mock_upload_file.content_type = "image/jpeg" + try: + validator._validate_content_type(mock_upload_file) + except ValidationError: + pytest.fail("Validation failed on wildcard match") + + def test_content_type_no_match(self, mock_upload_file: MagicMock): + validator = FileValidator(content_types=["image/png", "image/jpeg"]) + mock_upload_file.content_type = "text/plain" + + with pytest.raises(ValidationError) as e: + validator._validate_content_type(mock_upload_file) + + assert e.value.status_code == 415 + assert "unsupported media type" in e.value.detail + + def test_content_type_custom_error(self, mock_upload_file: MagicMock): + custom_error = "Only JPEGs allowed." + validator = FileValidator(content_types=["image/jpeg"], on_type_error_detail=custom_error) + mock_upload_file.content_type = "image/png" + + with pytest.raises(ValidationError) as e: + validator._validate_content_type(mock_upload_file) + + assert e.value.status_code == 415 + assert e.value.detail == custom_error + + def test_filename_no_rule(self, mock_upload_file: MagicMock): + validator = FileValidator() + try: + validator._validate_filename(mock_upload_file) + except ValidationError: + pytest.fail("Validation failed when no rule was set") + + def test_filename_match(self, mock_upload_file: MagicMock): + validator = FileValidator(filename_pattern=r"\.csv$") + mock_upload_file.filename = "data_export.csv" + try: + validator._validate_filename(mock_upload_file) + except ValidationError: + pytest.fail("Validation failed on filename match") + + def test_filename_no_match(self, mock_upload_file: MagicMock): + validator = FileValidator(filename_pattern=r"^[a-zA-Z]+$") + mock_upload_file.filename = "123_invalid.txt" + + with pytest.raises(ValidationError) as e: + validator._validate_filename(mock_upload_file) + + assert e.value.status_code == 400 + assert "does not match" in e.value.detail + + def test_filename_is_none(self, mock_upload_file: MagicMock): + """Tests that a None filename fails validation if a rule exists.""" + validator = FileValidator(filename_pattern=r".*") + mock_upload_file.filename = None + + with pytest.raises(ValidationError) as e: + validator._validate_filename(mock_upload_file) + + assert e.value.status_code == 400 + assert "Filename 'None'" in e.value.detail + + async def test_size_no_rule(self, mock_upload_file: MagicMock): + validator = FileValidator() + try: + await validator._validate_size(mock_upload_file) + except ValidationError: + pytest.fail("Size validation failed when no rule was set") + + async def test_size_from_header_valid(self, mock_upload_file: MagicMock): + validator = FileValidator(max_size="2MB", min_size="1KB") + mock_upload_file.size = 1024 * 1024 # 1MB (valid) + + try: + await validator._validate_size(mock_upload_file) + except ValidationError: + pytest.fail("Size validation failed on valid file size") + + async def test_size_from_header_too_large(self, mock_upload_file: MagicMock): + validator = FileValidator(max_size="1MB") + mock_upload_file.size = 1024 * 1024 + 1 # 1MB + 1 byte (invalid) + + with pytest.raises(ValidationError) as e: + await validator._validate_size(mock_upload_file) + + assert e.value.status_code == 413 + assert "exceeds the maximum limit" in e.value.detail + + async def test_size_from_header_too_small(self, mock_upload_file: MagicMock): + validator = FileValidator(min_size="2KB") + mock_upload_file.size = 1024 # 1KB (invalid) + + with pytest.raises(ValidationError) as e: + await validator._validate_size(mock_upload_file) + + assert e.value.status_code == 400 + assert "less than the minimum requirement" in e.value.detail + + async def test_size_from_stream_valid(self, mock_upload_file: MagicMock): + validator = FileValidator(max_size="2MB", min_size="1KB") + + # Mock streaming, file.size is None + mock_upload_file.size = None + # Stream will return 1024 bytes (from default fixture) + + try: + await validator._validate_size(mock_upload_file) + except ValidationError: + pytest.fail("Streaming size validation failed on valid file") + + async def test_size_from_stream_too_large(self, mock_upload_file: MagicMock): + validator = FileValidator(max_size="512B") + + mock_upload_file.size = None + # Stream will return 1024 bytes (default fixture) + + with pytest.raises(ValidationError) as e: + await validator._validate_size(mock_upload_file) + + assert e.value.status_code == 413 + assert "exceeds the maximum limit" in e.value.detail + + async def test_size_from_stream_too_small(self, mock_upload_file: MagicMock): + validator = FileValidator(min_size="2KB") + + mock_upload_file.size = None + # Stream will return 1024 bytes (default fixture) + + with pytest.raises(ValidationError) as e: + await validator._validate_size(mock_upload_file) + + assert e.value.status_code == 400 + assert "less than the minimum requirement" in e.value.detail + + async def test_size_custom_error(self, mock_upload_file: MagicMock): + custom_error = "That file is way too big." + validator = FileValidator(max_size="1KB", on_size_error_detail=custom_error) + mock_upload_file.size = 2000 # > 1KB + + with pytest.raises(ValidationError) as e: + await validator._validate_size(mock_upload_file) + + assert e.value.status_code == 413 + assert e.value.detail == custom_error From 762f96d4a1299ebabe3e883d6bbe89472d4216f2 Mon Sep 17 00:00:00 2001 From: Mohammed-Saajid Date: Sun, 26 Oct 2025 20:09:53 +0530 Subject: [PATCH 04/30] Added pytest asyncio --- pyproject.toml | 1 + uv.lock | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index c598ca5..0ca4f8b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ dev = [ "mkdocstrings[python]>=0.30.1", "mypy>=1.18.2", "pytest>=8.4.2", + "pytest-asyncio>=1.2.0", "ruff>=0.14.1", ] diff --git a/uv.lock b/uv.lock index 7aa0e58..7f87227 100644 --- a/uv.lock +++ b/uv.lock @@ -172,6 +172,7 @@ dev = [ { name = "mkdocstrings", extra = ["python"] }, { name = "mypy" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "ruff" }, ] @@ -190,6 +191,7 @@ dev = [ { name = "mkdocstrings", extras = ["python"], specifier = ">=0.30.1" }, { name = "mypy", specifier = ">=1.18.2" }, { name = "pytest", specifier = ">=8.4.2" }, + { name = "pytest-asyncio", specifier = ">=1.2.0" }, { name = "ruff", specifier = ">=0.14.1" }, ] @@ -832,6 +834,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750 }, ] +[[package]] +name = "pytest-asyncio" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095 }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" From 019db8b6e978aa2247d220937495347b4fda0a54 Mon Sep 17 00:00:00 2001 From: Mohammed-Saajid Date: Mon, 27 Oct 2025 13:31:37 +0530 Subject: [PATCH 05/30] Minor Docstring Changes --- fastapi_assets/validators/file_validator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fastapi_assets/validators/file_validator.py b/fastapi_assets/validators/file_validator.py index 3e57c60..a8109b0 100644 --- a/fastapi_assets/validators/file_validator.py +++ b/fastapi_assets/validators/file_validator.py @@ -31,7 +31,7 @@ class FileValidator(BaseValidator): MIME type, and filename. .. code-block:: python - from fastapi import FastAPI, UploadFile + from fastapi import FastAPI, UploadFile, Depends from fastapi_assets.validators.file_validator import FileValidator app = FastAPI() @@ -47,7 +47,7 @@ class FileValidator(BaseValidator): ) @app.post("/upload/") - async def upload_file(file: UploadFile = file_validator): + async def upload_file(file: UploadFile = Depends(file_validator)): return {"filename": file.filename} """ From d9b66e23b0069f1cb7d27846ddc5a7575581757a Mon Sep 17 00:00:00 2001 From: Mohammed-Saajid Date: Tue, 28 Oct 2025 08:34:19 +0530 Subject: [PATCH 06/30] Modified toml File for type hints --- pyproject.toml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0ca4f8b..00d10d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ version = "0.1.0" description = "FastAPI Assets - A validation and assertion toolkit for FastAPI file uploads and request metadata." readme = "README.md" requires-python = ">=3.12" -license = { file = "LICENSE" } +license = { file = "MIT" } dependencies = [ "fastapi>=0.119.1", ] @@ -17,6 +17,12 @@ image = [ "pillow>=12.0.0", ] +[tool.setuptools] +packages = ["fastapi_assets"] + +[tool.setuptools.package-data] +"fastapi_assets" = ["py.typed"] + [dependency-groups] dev = [ "mkdocs>=1.6.1", From 5d3bc02f132bfa5bfc06524a6bcb3fc54873fca0 Mon Sep 17 00:00:00 2001 From: Mohammed-Saajid Date: Tue, 28 Oct 2025 08:35:30 +0530 Subject: [PATCH 07/30] Formatted --- fastapi_assets/validators/file_validator.py | 1 + fastapi_assets/validators/utils.py | 1 + 2 files changed, 2 insertions(+) diff --git a/fastapi_assets/validators/file_validator.py b/fastapi_assets/validators/file_validator.py index a8109b0..c2df77e 100644 --- a/fastapi_assets/validators/file_validator.py +++ b/fastapi_assets/validators/file_validator.py @@ -1,4 +1,5 @@ """Module providing the FileValidator for validating uploaded files in FastAPI.""" + import re from typing import Any, Callable, List, Optional, Union from fastapi_assets.core.base_validator import BaseValidator, ValidationError diff --git a/fastapi_assets/validators/utils.py b/fastapi_assets/validators/utils.py index 3e4778f..7bbf6a2 100644 --- a/fastapi_assets/validators/utils.py +++ b/fastapi_assets/validators/utils.py @@ -1,4 +1,5 @@ """Utility functions for file validation in FastAPI Assets.""" + from typing import TYPE_CHECKING, List, Optional, Union import re import fnmatch From a3e3ad346e4a337384ce752c51ea5494b2edd550 Mon Sep 17 00:00:00 2001 From: Mohammed-Saajid Date: Tue, 28 Oct 2025 08:36:08 +0530 Subject: [PATCH 08/30] Added Image Validator --- fastapi_assets/validators/image_validator.py | 295 ++++++++++++ tests/test_image_validator.py | 469 +++++++++++++++++++ 2 files changed, 764 insertions(+) create mode 100644 fastapi_assets/validators/image_validator.py create mode 100644 tests/test_image_validator.py diff --git a/fastapi_assets/validators/image_validator.py b/fastapi_assets/validators/image_validator.py new file mode 100644 index 0000000..2b89ec6 --- /dev/null +++ b/fastapi_assets/validators/image_validator.py @@ -0,0 +1,295 @@ +""" +Module providing the ImageValidator for validating uploaded image files. +""" + +from typing import Any, Callable, List, Optional, Union +from fastapi_assets.core.base_validator import ValidationError +from fastapi import File, UploadFile +from starlette.datastructures import UploadFile as StarletteUploadFile +from fastapi_assets.validators.file_validator import FileValidator + +# Pillow Dependency Handling +try: + # Pillow is required for ImageValidator + from PIL import Image, UnidentifiedImageError +except ImportError: + # Raise a clear, actionable error if Pillow is not installed + raise ImportError( + "Pillow is not installed. " + "Please run 'pip install fastapi-assets[image]' to use ImageValidator." + ) + +# ImageValidator Implementation + +_DEFAULT_IMAGE_CONTENT_TYPES = [ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "image/bmp", + "image/tiff", +] + + +class ImageValidator(FileValidator): + r""" + A specialized dependency for validating image `UploadFile` objects. + + Inherits all checks from `FileValidator` (size, filename) and adds + validations for image format, resolution, and aspect ratio by + inspecting the file content with Pillow. + + Requires `fastapi-assets[image]` to be installed. + + .. code-block:: python + from fastapi import FastAPI, UploadFile, Depends + from fastapi_assets.validators.image_validator import ImageValidator + + app = FastAPI() + + image_validator = ImageValidator( + max_size="5MB", + allowed_formats=["JPEG", "PNG"], + min_resolution=(640, 480), + max_resolution=(1920, 1080), + aspect_ratios=["16:9", "4:3"], + on_format_error_detail="Only JPEG and PNG images are allowed.", + on_resolution_error_detail="Image must be between 640x480 and 1920x1080." + ) + + @app.post("/upload/image/") + async def upload_image(image: UploadFile = Depends(image_validator)): + return {"filename": image.filename, "format": image.content_type} + """ + + def __init__( + self, + *, + allowed_formats: Optional[List[str]] = None, + min_resolution: Optional[tuple[int, int]] = None, + max_resolution: Optional[tuple[int, int]] = None, + exact_resolution: Optional[tuple[int, int]] = None, + aspect_ratios: Optional[List[str]] = None, + aspect_ratio_tolerance: float = 0.05, + on_format_error_detail: Optional[Union[str, Callable[[Any], str]]] = None, + on_resolution_error_detail: Optional[Union[str, Callable[[Any], str]]] = None, + on_aspect_ratio_error_detail: Optional[Union[str, Callable[[Any], str]]] = None, + **kwargs: Any, + ): + """ + Initializes the ImageValidator. + + Args: + allowed_formats: List of allowed image formats (e.g., ['JPEG', 'PNG']). + min_resolution: (width, height) tuple for minimum dimensions. + max_resolution: (width, height) tuple for maximum dimensions. + exact_resolution: (width, height) tuple for exact dimensions. + aspect_ratios: List of allowed aspect ratios (e.g., ['16:9', '1:1']). + aspect_ratio_tolerance: Float for allowed deviation from aspect ratios. + on_size_error_detail: Custom error for size failures. + on_type_error_detail: Custom error for content-type failures. + on_filename_error_detail: Custom error for filename failures. + on_format_error_detail: Custom error for format (Pillow) failures. + on_resolution_error_detail: Custom error for resolution failures. + on_aspect_ratio_error_detail: Custom error for aspect ratio failures. + **kwargs: Catches all parent arguments. + """ + + # Set default image content types if not overridden + if "content_types" not in kwargs: + kwargs["content_types"] = _DEFAULT_IMAGE_CONTENT_TYPES + + kwargs["error_detail"] = ( + "Image validation failed." if "error_detail" not in kwargs else kwargs["error_detail"] + ) + + # Initialize parent FileValidator + super().__init__(**kwargs) + + # Store image-specific rules + # Normalize formats to uppercase for reliable comparison + self._allowed_formats = [f.upper() for f in allowed_formats] if allowed_formats else None + self._min_resolution = min_resolution + self._max_resolution = max_resolution + self._exact_resolution = exact_resolution + + # Store original string ratios for error messages + self._aspect_ratio_strings = aspect_ratios + self._aspect_ratios = self._parse_aspect_ratios(aspect_ratios) + self._aspect_ratio_tolerance = aspect_ratio_tolerance + + # Store image-specific error details + self._format_error_detail = on_format_error_detail + self._resolution_error_detail = on_resolution_error_detail + self._aspect_ratio_error_detail = on_aspect_ratio_error_detail + + def _parse_aspect_ratios(self, ratios: Optional[List[str]]) -> Optional[List[float]]: + """Helper to convert 'W:H' strings to float ratios. + Args: + ratios: List of aspect ratio strings (e.g., ['16:9']). + Returns: + List of float ratios (e.g., [1.777...]) or None. + """ + if not ratios: + return None + parsed = [] + for r in ratios: + try: + w_str, h_str = r.split(":") + w, h = int(w_str), int(h_str) + if h == 0: + raise ValueError("Aspect ratio height cannot be zero.") + parsed.append(w / h) + except (ValueError, AttributeError, TypeError): + raise ValueError( + f"Invalid aspect_ratios format: '{r}'. Expected 'W:H' (e.g., '16:9')." + ) + return parsed + + async def __call__(self, file: UploadFile = File(...), **kwargs: Any) -> StarletteUploadFile: + """ + FastAPI dependency entry point for image validation. + Args: + file: The uploaded image file to validate. + Returns: + The validated UploadFile object. + """ + # Run all parent validations (size, content-type, filename) + # This will also rewind the file stream to position 0. + try: + await super().__call__(file, **kwargs) + except ValidationError as e: + # Re-raise the exception from the parent + self._raise_error(status_code=e.status_code, detail=str(e.detail)) + + # Run image-specific validations using Pillow + img = None + try: + # `file.file` is a SpooledTemporaryFile, which Image.open can read. + img = Image.open(file.file) + + # Perform content-based validations + self._validate_format(img) + self._validate_resolution(img) + self._validate_aspect_ratio(img) + + except (UnidentifiedImageError, IOError) as e: + # Pillow couldn't identify it as an image, or file is corrupt + detail = ( + self._format_error_detail + or f"File is not a valid image or is corrupted. Error: {e}" + ) + self._raise_error(status_code=415, detail=detail) + + except ValidationError as e: + # One of our own _validate methods failed + self._raise_error(status_code=e.status_code, detail=str(e.detail)) + + except Exception as e: + # Catch-all for other unexpected errors during Pillow validation + await file.close() + self._raise_error( + status_code=400, + detail=f"An unexpected error occurred during image validation: {e}", + ) + finally: + if img: + img.close() + + # CRITICAL: Rewind the file stream *again* so the endpoint + # can read it after Pillow is done. + await file.seek(0) + + return file + + def _validate_format(self, img: Image.Image) -> None: + """Checks the image's actual format (e.g., 'JPEG', 'PNG'). + Args: + img: The opened PIL Image object. + Returns: + None + Raises: + ValidationError: If the image format is not allowed. + """ + if not self._allowed_formats: + return # No rule set + + img_format = img.format + if not img_format or img_format not in self._allowed_formats: + detail = self._format_error_detail or ( + f"Unsupported image format: '{img_format}'. " + f"Allowed formats are: {', '.join(self._allowed_formats)}" + ) + # 415 Unsupported Media Type + raise ValidationError(detail=str(detail), status_code=415) + + def _validate_resolution(self, img: Image.Image) -> None: + """Checks image dimensions against min, max, and exact constraints. + Args: + img: The opened PIL Image object. + Returns: + None + Raises: + ValidationError: If the image resolution is out of bounds + """ + if not (self._min_resolution or self._max_resolution or self._exact_resolution): + return # No resolution rules set + + width, height = img.size + err_msg = None + + if self._exact_resolution: + ex_w, ex_h = self._exact_resolution + if (width, height) != (ex_w, ex_h): + err_msg = f"Image resolution must be exactly {ex_w}x{ex_h}. Got {width}x{height}." + + if err_msg is None and self._min_resolution: + min_w, min_h = self._min_resolution + if width < min_w or height < min_h: + err_msg = ( + f"Image resolution ({width}x{height}) is below the minimum of {min_w}x{min_h}." + ) + + if err_msg is None and self._max_resolution: + max_w, max_h = self._max_resolution + if width > max_w or height > max_h: + err_msg = ( + f"Image resolution ({width}x{height}) exceeds the maximum of {max_w}x{max_h}." + ) + + if err_msg: + detail = self._resolution_error_detail or err_msg + raise ValidationError(detail=str(detail), status_code=400) + + def _validate_aspect_ratio(self, img: Image.Image) -> None: + """Checks the image's aspect ratio against a list of allowed ratios. + Args: + img: The opened PIL Image object. + Returns: + None + Raises: + ValidationError: If the image's aspect ratio is not allowed. + """ + if not self._aspect_ratios: + return # No rule set + + width, height = img.size + if height == 0: + raise ValidationError( + detail="Image has zero height and aspect ratio cannot be calculated.", + status_code=400, + ) + + actual_ratio = width / height + + for target_ratio in self._aspect_ratios: + if abs(actual_ratio - target_ratio) <= self._aspect_ratio_tolerance: + return # Valid match found + + # No match found + ratio_strings = self._aspect_ratio_strings or [] + detail = self._aspect_ratio_error_detail or ( + f"Image aspect ratio ({width}:{height} ≈ {actual_ratio:.2f}) is not allowed. " + f"Allowed ratios are: {', '.join(ratio_strings)}" + ) + raise ValidationError(detail=str(detail), status_code=400) diff --git a/tests/test_image_validator.py b/tests/test_image_validator.py new file mode 100644 index 0000000..104746b --- /dev/null +++ b/tests/test_image_validator.py @@ -0,0 +1,469 @@ +""" +Test suite for the ImageValidator class. + +This suite uses pytest and pytest-asyncio to validate all features +of the ImageValidator, including inherited checks, image-specific +validations (format, resolution, aspect ratio), and error handling. +""" + +import io +from typing import Tuple +from unittest.mock import MagicMock, AsyncMock + +import pytest +from fastapi import HTTPException, UploadFile +from PIL import Image + +# Mock Dependencies +# To make this test file runnable, we must mock the classes +# ImageValidator inherits from or imports. + + +class MockValidationError(Exception): + """Mock a ValidationError for testing.""" + + def __init__(self, detail: str, status_code: int): + self.detail = detail + self.status_code = status_code + super().__init__(detail) + + +class MockBaseValidator: + """Mock the BaseValidator class.""" + + def __init__(self, status_code: int = 400, error_detail: str = "Validation failed."): + self.status_code = status_code + self.error_detail = error_detail + + def _raise_error(self, status_code: int, detail: str) -> None: + """Mock the error raising to throw HTTPException, as a FastAPI dependency would.""" + raise HTTPException(status_code=status_code, detail=detail) + + +class MockFileValidator(MockBaseValidator): + """Mock the FileValidator class to simulate its behavior.""" + + _DEFAULT_CHUNK_SIZE = 65_536 + + def __init__( + self, + max_size: int = None, + min_size: int = None, + content_types: list[str] = None, + **kwargs, + ): + super().__init__(**kwargs) + self._max_size = max_size + self._min_size = min_size + self._content_types = content_types + + async def __call__(self, file: UploadFile, **kwargs): + """Simulate FileValidator's __call__ logic for testing inheritance.""" + # Mock size check + if self._max_size is not None: + # Get file size + file.file.seek(0, io.SEEK_END) + file_size = file.file.tell() + file.file.seek(0) + + if file_size > self._max_size: + raise MockValidationError(detail="File is too large.", status_code=413) + + # Mock content-type check + if self._content_types and file.content_type not in self._content_types: + raise MockValidationError(detail="Invalid content type.", status_code=415) + + # Critical: rewind file for next operation + await file.seek(0) + return file + + +# Monkeypatch the Imports in the Module Under Test +# We replace the real imports with our mocks *before* importing ImageValidator + +import sys +from unittest.mock import MagicMock + +# Create mock modules +mock_base_validator = MagicMock() +mock_base_validator.ValidationError = MockValidationError +mock_base_validator.BaseValidator = MockBaseValidator + +mock_file_validator = MagicMock() +mock_file_validator.FileValidator = MockFileValidator + +# Put them in sys.modules +sys.modules["fastapi_assets.core.base_validator"] = mock_base_validator +sys.modules["fastapi_assets.validators.file_validator"] = mock_file_validator + +# Import the Class Under Test +# Now, when ImageValidator is imported, it will use our mocks +from fastapi_assets.validators.image_validator import ( # noqa: E402 + ImageValidator, + _DEFAULT_IMAGE_CONTENT_TYPES, +) + +# Test Helper Functions + + +def create_mock_image_file( + filename: str, + content_type: str, + img_format: str, + size: Tuple[int, int], + color: str = "blue", +) -> UploadFile: + """Creates an in-memory mock image UploadFile.""" + buffer = io.BytesIO() + img = Image.new("RGB", size, color=color) + img.save(buffer, format=img_format) + buffer.seek(0) + + # Save the original close method + original_close = buffer.close + # Override close to prevent actual closing during tests + buffer.close = lambda: None + + # Use MagicMock to create a mock UploadFile with settable content_type + file = MagicMock(spec=UploadFile) + file.filename = filename + file.content_type = content_type + file.file = buffer + + # Create a wrapper for seek + async def mock_seek(offset): + buffer.seek(offset) + + # Create a wrapper for read + async def mock_read(): + return buffer.read() + + async def mock_close(): + # Don't actually close the buffer so tests can still access it + pass + + file.read = AsyncMock(side_effect=mock_read) + file.seek = AsyncMock(side_effect=mock_seek) + file.close = AsyncMock(side_effect=mock_close) + return file + + +def create_mock_text_file(filename: str) -> UploadFile: + """Creates an in-memory mock text UploadFile.""" + buffer = io.BytesIO(b"This is not an image, just plain text.") + buffer.seek(0) + + # Override close to prevent actual closing during tests + buffer.close = lambda: None + + # Use MagicMock to create a mock UploadFile with settable content_type + file = MagicMock(spec=UploadFile) + file.filename = filename + file.content_type = "text/plain" + file.file = buffer + + # Create a wrapper for seek + async def mock_seek(offset): + buffer.seek(offset) + + # Create a wrapper for read + async def mock_read(): + return buffer.read() + + async def mock_close(): + # Don't actually close the buffer so tests can still access it + pass + + file.read = AsyncMock(side_effect=mock_read) + file.seek = AsyncMock(side_effect=mock_seek) + file.close = AsyncMock(side_effect=mock_close) + return file + + +# Test Suite + + +@pytest.mark.asyncio +class TestImageValidator: + """Test suite for the ImageValidator.""" + + async def test_valid_image_passes(self): + """ + Tests that a fully valid image passes all checks. + """ + validator = ImageValidator( + max_size=1024 * 1024, # 1MB + allowed_formats=["PNG"], + min_resolution=(100, 100), + max_resolution=(500, 500), + aspect_ratios=["1:1"], + ) + + file = create_mock_image_file("valid.png", "image/png", "PNG", (200, 200)) + + try: + validated_file = await validator(file) + assert validated_file == file + # Check that the file is rewound for the endpoint to read + if not file.file.closed: + assert file.file.tell() == 0 + finally: + await file.close() + + async def test_file_is_rewound_after_validation(self): + """ + Tests that the file stream is rewound to 0 twice: + 1. After the parent FileValidator checks (if any). + 2. After the ImageValidator (Pillow) checks. + """ + validator = ImageValidator(allowed_formats=["PNG"]) + file = create_mock_image_file("test.png", "image/png", "PNG", (50, 50)) + + # Get the original size + file.file.seek(0, io.SEEK_END) + original_size = file.file.tell() + file.file.seek(0) + + assert original_size > 0 + + try: + await validator(file) + + # Check 1: Is file pointer at 0? + assert file.file.tell() == 0 + + # Check 2: Can we read the full content? + content = await file.read() + assert len(content) == original_size + finally: + await file.close() # Initialization Tests + + def test_init_sets_defaults(self): + """Tests that the validator sets default image content types.""" + validator = ImageValidator() + assert validator._content_types == _DEFAULT_IMAGE_CONTENT_TYPES + assert validator._allowed_formats is None + + def test_init_overrides_content_types(self): + """Tests that 'content_types' in kwargs overrides the default.""" + validator = ImageValidator(content_types=["image/foo"]) + assert validator._content_types == ["image/foo"] + + def test_init_parses_aspect_ratios(self): + """Tests that string aspect ratios are correctly parsed to floats.""" + validator = ImageValidator(aspect_ratios=["16:9", "1:1", "4:3"]) + assert validator._aspect_ratios is not None + assert pytest.approx(validator._aspect_ratios[0]) == 16 / 9 + assert pytest.approx(validator._aspect_ratios[1]) == 1.0 + assert pytest.approx(validator._aspect_ratios[2]) == 4 / 3 + + def test_init_invalid_aspect_ratio_raises(self): + """Tests that a malformed aspect ratio string raises a ValueError.""" + with pytest.raises(ValueError, match="Invalid aspect_ratios format: '16-9'"): + ImageValidator(aspect_ratios=["16-9"]) + + with pytest.raises(ValueError, match="Invalid aspect_ratios format: '1:0'"): + ImageValidator(aspect_ratios=["1:0"]) + + # Inherited Validation Tests + + async def test_inherited_max_size_failure(self): + """ + Tests that the parent FileValidator check (max_size) is + correctly called and raises an error. + """ + # Set max_size to 100 bytes. The mock image will be larger. + validator = ImageValidator(max_size=100) + file = create_mock_image_file("large.png", "image/png", "PNG", (100, 100)) + + try: + with pytest.raises(HTTPException) as exc_info: + await validator(file) + + assert exc_info.value.status_code == 413 # From our mock + assert "File is too large" in exc_info.value.detail + finally: + await file.close() + + # Image-Specific Validation Tests + + async def test_invalid_format_failure(self): + """Tests failure when Pillow-detected format is not in allowed_formats.""" + validator = ImageValidator(allowed_formats=["JPEG"]) + file = create_mock_image_file("image.png", "image/png", "PNG", (100, 100)) + + try: + with pytest.raises(HTTPException) as exc_info: + await validator(file) + + assert exc_info.value.status_code == 415 + assert "Unsupported image format: 'PNG'" in exc_info.value.detail + finally: + await file.close() + + async def test_not_an_image_file_failure(self): + """Tests failure when the file is not a valid image (e.g., text).""" + validator = ImageValidator(allowed_formats=["JPEG"]) + + # Create a mock that has image content_type but contains text + buffer = io.BytesIO(b"This is not an image, just plain text.") + buffer.seek(0) + buffer.close = lambda: None # Prevent closing + + file = MagicMock(spec=UploadFile) + file.filename = "fake.jpg" + file.content_type = "image/jpeg" # Looks like JPEG but isn't + file.file = buffer + + async def mock_seek(offset): + buffer.seek(offset) + + async def mock_read(): + return buffer.read() + + async def mock_close(): + pass + + file.read = AsyncMock(side_effect=mock_read) + file.seek = AsyncMock(side_effect=mock_seek) + file.close = AsyncMock(side_effect=mock_close) + + try: + with pytest.raises(HTTPException) as exc_info: + await validator(file) + + assert exc_info.value.status_code == 415 + assert "File is not a valid image" in exc_info.value.detail + finally: + await file.close() + + async def test_min_resolution_failure(self): + """Tests failure when image dimensions are below min_resolution.""" + validator = ImageValidator(min_resolution=(200, 200)) + file = create_mock_image_file("small.png", "image/png", "PNG", (100, 100)) + + try: + with pytest.raises(HTTPException) as exc_info: + await validator(file) + + assert exc_info.value.status_code == 400 + assert "is below the minimum of 200x200" in exc_info.value.detail + finally: + await file.close() + + async def test_max_resolution_failure(self): + """Tests failure when image dimensions are above max_resolution.""" + validator = ImageValidator(max_resolution=(50, 50)) + file = create_mock_image_file("large.png", "image/png", "PNG", (100, 100)) + + try: + with pytest.raises(HTTPException) as exc_info: + await validator(file) + + assert exc_info.value.status_code == 400 + assert "exceeds the maximum of 50x50" in exc_info.value.detail + finally: + await file.close() + + async def test_exact_resolution_failure(self): + """Tests failure when image dimensions do not match exact_resolution.""" + validator = ImageValidator(exact_resolution=(100, 100)) + file = create_mock_image_file("wrong.png", "image/png", "PNG", (101, 100)) + + try: + with pytest.raises(HTTPException) as exc_info: + await validator(file) + + assert exc_info.value.status_code == 400 + assert "must be exactly 100x100" in exc_info.value.detail + finally: + await file.close() + + async def test_exact_resolution_success(self): + """Tests success when image dimensions match exact_resolution.""" + validator = ImageValidator(exact_resolution=(100, 100)) + file = create_mock_image_file("correct.png", "image/png", "PNG", (100, 100)) + + try: + await validator(file) + # No exception raised + finally: + await file.close() + + async def test_aspect_ratio_failure(self): + """Tests failure when image aspect ratio is not in the allowed list.""" + validator = ImageValidator(aspect_ratios=["1:1", "16:9"]) + # Create a 4:3 image + file = create_mock_image_file("4_3.png", "image/png", "PNG", (800, 600)) + + try: + with pytest.raises(HTTPException) as exc_info: + await validator(file) + + assert exc_info.value.status_code == 400 + assert "aspect ratio (800:600" in exc_info.value.detail + assert "Allowed ratios are: 1:1, 16:9" in exc_info.value.detail + finally: + await file.close() + + async def test_aspect_ratio_tolerance_success(self): + """ + Tests that an image with an aspect ratio *close* to the target + passes when a tolerance is specified. + """ + # 16:9 is ~1.777. Our image is 178:100 = 1.78 + validator = ImageValidator(aspect_ratios=["16:9"], aspect_ratio_tolerance=0.01) + file = create_mock_image_file("off_16_9.png", "image/png", "PNG", (178, 100)) + + try: + await validator(file) + # No exception raised, 1.78 is within 0.01 of 1.777 + finally: + await file.close() + + # Custom Error Message Tests + + async def test_custom_format_error_message(self): + """Tests that 'on_format_error_detail' provides a custom message.""" + custom_msg = "Only JPEGs are allowed, please." + validator = ImageValidator(allowed_formats=["JPEG"], on_format_error_detail=custom_msg) + file = create_mock_image_file("image.png", "image/png", "PNG", (100, 100)) + + try: + with pytest.raises(HTTPException) as exc_info: + await validator(file) + + assert exc_info.value.status_code == 415 + assert exc_info.value.detail == custom_msg + finally: + await file.close() + + async def test_custom_resolution_error_message(self): + """Tests that 'on_resolution_error_detail' provides a custom message.""" + custom_msg = "Image is too small." + validator = ImageValidator(min_resolution=(200, 200), on_resolution_error_detail=custom_msg) + file = create_mock_image_file("small.png", "image/png", "PNG", (100, 100)) + + try: + with pytest.raises(HTTPException) as exc_info: + await validator(file) + + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == custom_msg + finally: + await file.close() + + async def test_custom_aspect_ratio_error_message(self): + """Tests that 'on_aspect_ratio_error_detail' provides a custom message.""" + custom_msg = "Image must be square." + validator = ImageValidator(aspect_ratios=["1:1"], on_aspect_ratio_error_detail=custom_msg) + file = create_mock_image_file("16_9.png", "image/png", "PNG", (1920, 1080)) + + try: + with pytest.raises(HTTPException) as exc_info: + await validator(file) + + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == custom_msg + finally: + await file.close() From 8a25533dd884d0ddb1c9c92070737abe50c38475 Mon Sep 17 00:00:00 2001 From: Mohammed-Saajid Date: Tue, 28 Oct 2025 08:53:13 +0530 Subject: [PATCH 09/30] Modified Workflow --- .github/workflows/ci.yaml | 1 + pyproject.toml | 27 ++++++++++----------------- uv.lock | 39 +++++++++++++++++++-------------------- 3 files changed, 30 insertions(+), 37 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index fc52689..3b187ee 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -17,6 +17,7 @@ jobs: pip install uv uv venv uv pip install -e .[dev] + uv pip install -e .[optional] - name: Run tests run: | uv run pytest diff --git a/pyproject.toml b/pyproject.toml index 00d10d5..bc7b2c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,25 +5,11 @@ description = "FastAPI Assets - A validation and assertion toolkit for FastAPI f readme = "README.md" requires-python = ">=3.12" license = { file = "MIT" } -dependencies = [ - "fastapi>=0.119.1", -] +dependencies = ["fastapi>=0.119.1"] [project.optional-dependencies] -pandas = [ - "pandas>=2.3.3", -] -image = [ - "pillow>=12.0.0", -] - -[tool.setuptools] -packages = ["fastapi_assets"] - -[tool.setuptools.package-data] -"fastapi_assets" = ["py.typed"] - -[dependency-groups] +pandas = ["pandas>=2.3.3"] +image = ["pillow>=12.0.0"] dev = [ "mkdocs>=1.6.1", "mkdocs-material>=9.6.22", @@ -33,6 +19,13 @@ dev = [ "pytest-asyncio>=1.2.0", "ruff>=0.14.1", ] +optional = ["fastapi-assets[pandas,image]"] + +[tool.setuptools] +packages = ["fastapi_assets"] + +[tool.setuptools.package-data] +"fastapi_assets" = ["py.typed"] # Pytest Configuration diff --git a/uv.lock b/uv.lock index 7f87227..2e16f3b 100644 --- a/uv.lock +++ b/uv.lock @@ -158,14 +158,6 @@ dependencies = [ ] [package.optional-dependencies] -image = [ - { name = "pillow" }, -] -pandas = [ - { name = "pandas" }, -] - -[package.dev-dependencies] dev = [ { name = "mkdocs" }, { name = "mkdocs-material" }, @@ -175,25 +167,32 @@ dev = [ { name = "pytest-asyncio" }, { name = "ruff" }, ] +image = [ + { name = "pillow" }, +] +optional = [ + { name = "pandas" }, + { name = "pillow" }, +] +pandas = [ + { name = "pandas" }, +] [package.metadata] requires-dist = [ { name = "fastapi", specifier = ">=0.119.1" }, + { name = "fastapi-assets", extras = ["pandas", "image"], marker = "extra == 'optional'" }, + { name = "mkdocs", marker = "extra == 'dev'", specifier = ">=1.6.1" }, + { name = "mkdocs-material", marker = "extra == 'dev'", specifier = ">=9.6.22" }, + { name = "mkdocstrings", extras = ["python"], marker = "extra == 'dev'", specifier = ">=0.30.1" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.18.2" }, { name = "pandas", marker = "extra == 'pandas'", specifier = ">=2.3.3" }, { name = "pillow", marker = "extra == 'image'", specifier = ">=12.0.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.4.2" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.2.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.14.1" }, ] -provides-extras = ["pandas", "image"] - -[package.metadata.requires-dev] -dev = [ - { name = "mkdocs", specifier = ">=1.6.1" }, - { name = "mkdocs-material", specifier = ">=9.6.22" }, - { name = "mkdocstrings", extras = ["python"], specifier = ">=0.30.1" }, - { name = "mypy", specifier = ">=1.18.2" }, - { name = "pytest", specifier = ">=8.4.2" }, - { name = "pytest-asyncio", specifier = ">=1.2.0" }, - { name = "ruff", specifier = ">=0.14.1" }, -] +provides-extras = ["pandas", "image", "dev", "optional"] [[package]] name = "ghp-import" From 9413de3ebc27a1a7834a4ed0152eb03b0300372c Mon Sep 17 00:00:00 2001 From: Mohammed-Saajid Date: Tue, 28 Oct 2025 12:28:00 +0530 Subject: [PATCH 10/30] Resolved Module Import Logic --- fastapi_assets/validators/image_validator.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/fastapi_assets/validators/image_validator.py b/fastapi_assets/validators/image_validator.py index 2b89ec6..8b50f95 100644 --- a/fastapi_assets/validators/image_validator.py +++ b/fastapi_assets/validators/image_validator.py @@ -8,17 +8,13 @@ from starlette.datastructures import UploadFile as StarletteUploadFile from fastapi_assets.validators.file_validator import FileValidator +PIL = True # Pillow Dependency Handling try: # Pillow is required for ImageValidator from PIL import Image, UnidentifiedImageError except ImportError: - # Raise a clear, actionable error if Pillow is not installed - raise ImportError( - "Pillow is not installed. " - "Please run 'pip install fastapi-assets[image]' to use ImageValidator." - ) - + PIL = None # ImageValidator Implementation _DEFAULT_IMAGE_CONTENT_TYPES = [ @@ -94,7 +90,12 @@ def __init__( on_aspect_ratio_error_detail: Custom error for aspect ratio failures. **kwargs: Catches all parent arguments. """ - + if not PIL: + raise ImportError( + "The 'Pillow' library is required for ImageValidator. " + "Install it with 'pip install fastapi-assets[image]'" + ) + # Set default image content types if not overridden if "content_types" not in kwargs: kwargs["content_types"] = _DEFAULT_IMAGE_CONTENT_TYPES From 1e0b1aa8b9f137c780f3b984b892272869cfb660 Mon Sep 17 00:00:00 2001 From: Mohammed-Saajid Date: Tue, 28 Oct 2025 12:49:55 +0530 Subject: [PATCH 11/30] Resolved Dependency Import Issue --- fastapi_assets/validators/image_validator.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/fastapi_assets/validators/image_validator.py b/fastapi_assets/validators/image_validator.py index 8b50f95..5e43cfc 100644 --- a/fastapi_assets/validators/image_validator.py +++ b/fastapi_assets/validators/image_validator.py @@ -8,13 +8,14 @@ from starlette.datastructures import UploadFile as StarletteUploadFile from fastapi_assets.validators.file_validator import FileValidator -PIL = True # Pillow Dependency Handling try: # Pillow is required for ImageValidator from PIL import Image, UnidentifiedImageError + PIL = True except ImportError: - PIL = None + PIL = None # type: ignore + # ImageValidator Implementation _DEFAULT_IMAGE_CONTENT_TYPES = [ From 65179770f49cbbee9cc9f023821751ee7e48d5bc Mon Sep 17 00:00:00 2001 From: Mohammed-Saajid Date: Tue, 28 Oct 2025 12:50:40 +0530 Subject: [PATCH 12/30] Resolved Kwargs in File Validator --- fastapi_assets/validators/file_validator.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/fastapi_assets/validators/file_validator.py b/fastapi_assets/validators/file_validator.py index c2df77e..0cbd06d 100644 --- a/fastapi_assets/validators/file_validator.py +++ b/fastapi_assets/validators/file_validator.py @@ -64,6 +64,7 @@ def __init__( on_size_error_detail: Optional[Union[str, Callable[[Any], str]]] = None, on_type_error_detail: Optional[Union[str, Callable[[Any], str]]] = None, on_filename_error_detail: Optional[Union[str, Callable[[Any], str]]] = None, + **kwargs: Any, ): """ Initializes the FileValidator. @@ -76,10 +77,13 @@ def __init__( on_size_error_detail: Custom error message for size validation failures. on_type_error_detail: Custom error message for content-type failures. on_filename_error_detail: Custom error message for filename pattern failures. + **kwargs: Additional arguments for the BaseValidator. """ # Call super() with a generic default, which will be overridden # by the specific error handlers. - super().__init__(status_code=400, error_detail="File validation failed.") + kwargs["error_detail"] = kwargs.get("error_detail", "File validation failed.") + kwargs["status_code"] = 400 + super().__init__(**kwargs) # Parse sizes once self._max_size = ( From d8c621944bf65e47263772beb185bf0701ad05e2 Mon Sep 17 00:00:00 2001 From: Mohammed-Saajid Date: Tue, 28 Oct 2025 12:52:01 +0530 Subject: [PATCH 13/30] Resolved File Streaming Logic --- fastapi_assets/validators/utils.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/fastapi_assets/validators/utils.py b/fastapi_assets/validators/utils.py index 7bbf6a2..85d4642 100644 --- a/fastapi_assets/validators/utils.py +++ b/fastapi_assets/validators/utils.py @@ -80,7 +80,11 @@ async def _get_streamed_size(self: "FileValidator", file: UploadFile, size_units ValidationError: If the file exceeds max_size during streaming. """ actual_size = 0 - async for chunk in file.chunks(self._DEFAULT_CHUNK_SIZE): # type: ignore[attr-defined] + chunk_size = self._DEFAULT_CHUNK_SIZE + while True: + chunk = await file.read(chunk_size) + if not chunk: + break actual_size += len(chunk) if self._max_size is not None and actual_size > self._max_size: From be69ee0b6ec630a9c35b8bdfdd48e5503829aef6 Mon Sep 17 00:00:00 2001 From: Mohammed-Saajid Date: Tue, 28 Oct 2025 17:56:39 +0530 Subject: [PATCH 14/30] Implemented Csv Validator --- fastapi_assets/validators/csv_validator.py | 359 +++++++++++++++++++++ tests/test_csv_validator.py | 320 ++++++++++++++++++ 2 files changed, 679 insertions(+) create mode 100644 fastapi_assets/validators/csv_validator.py create mode 100644 tests/test_csv_validator.py diff --git a/fastapi_assets/validators/csv_validator.py b/fastapi_assets/validators/csv_validator.py new file mode 100644 index 0000000..cc0f9f2 --- /dev/null +++ b/fastapi_assets/validators/csv_validator.py @@ -0,0 +1,359 @@ +"""Module providing the CSVValidator for validating CSV files.""" + +from typing import Any, Callable, List, Optional, Union +from fastapi import File, UploadFile +from starlette.datastructures import UploadFile as StarletteUploadFile + +# Import from base file_validator module +from fastapi_assets.core.base_validator import ValidationError +from fastapi_assets.validators.file_validator import ( + FileValidator, +) + +# Handle Optional Pandas Dependency +try: + import pandas as pd +except ImportError: + pd = None + + +class CSVValidator(FileValidator): + r""" + A specialized dependency for validating CSV `UploadFile` objects. + + It inherits all checks from `FileValidator` (size, filename) and adds + CSV-specific checks for encoding, delimiter, columns, and row counts. + + This validator requires the 'pandas' library. Install it with: + `pip install fastapi-asserts[csv]` + + .. code-block:: python + from fastapi import FastAPI, UploadFile, Depends + from fastapi_assets.validators.csv_validator import CSVValidator + + app = FastAPI() + + csv_validator = CSVValidator( + max_size="5MB", + encoding='utf-8', + delimiter=',', + required_columns=['id', 'name', 'email'], + disallowed_columns=['password'], + min_rows=1, + max_rows=1000, + header_check_only=True # Efficiently check rows + ) + + @app.post("/upload-csv/") + async def upload_csv_file(file: UploadFile = Depends(csv_validator)): + # File is guaranteed to be a valid CSV within spec + return {"filename": file.filename, "status": "validated"} + """ + + _DEFAULT_CSV_CONTENT_TYPES = ["text/csv", "application/vnd.ms-excel"] + + def __init__( + self, + *, + # CSV-Specific Arguments + encoding: Optional[Union[str, List[str]]] = None, + delimiter: Optional[str] = None, + required_columns: Optional[List[str]] = None, + exact_columns: Optional[List[str]] = None, + disallowed_columns: Optional[List[str]] = None, + min_rows: Optional[int] = None, + max_rows: Optional[int] = None, + header_check_only: bool = True, + # CSV-Specific Error Messages + on_encoding_error_detail: Optional[Union[str, Callable[[Any], str]]] = None, + on_column_error_detail: Optional[Union[str, Callable[[Any], str]]] = None, + on_row_error_detail: Optional[Union[str, Callable[[Any], str]]] = None, + on_parse_error_detail: Optional[Union[str, Callable[[Any], str]]] = None, + # Inherited FileValidator Arguments + **kwargs: Any, + ): + """ + Initializes the CSVValidator. + + Args: + encoding: A string or list of allowed file encodings (e.g., 'utf-8'). + delimiter: The expected delimiter (e.g., ','). + required_columns: List of column names that must be present. + exact_columns: List of column names that must match the header exactly. + disallowed_columns: List of column names that must not be present. + min_rows: Minimum number of data rows (excluding header). + max_rows: Maximum number of data rows (excluding header). + header_check_only: If True (default), validates rows efficiently + by reading only enough of the file to check bounds. If False, + streams the entire file to get an exact row count. + on_encoding_error_detail: Custom error message for encoding failures. + on_column_error_detail: Custom error message for column validation failures. + on_row_error_detail: Custom error message for row count failures. + on_parse_error_detail: Custom error message for general CSV parsing errors. + **kwargs: Any additional keyword arguments to pass to the parent class. + """ + if not pd: + raise ImportError( + "The 'pandas' library is required for CSVValidator. " + "Install it with 'pip install fastapi-assets[csv]'" + ) + + kwargs["content_types"] = kwargs.get("content_types", self._DEFAULT_CSV_CONTENT_TYPES) + + kwargs["error_detail"] = kwargs.get("error_detail", "CSV file validation failed.") + + # Initialize parent FileValidator + super().__init__(**kwargs) + + # Store CSV-specific rules + self._encoding = [encoding] if isinstance(encoding, str) else encoding + self._delimiter = delimiter + self._required_columns = set(required_columns) if required_columns else None + self._exact_columns = exact_columns + self._disallowed_columns = set(disallowed_columns) if disallowed_columns else None + self._min_rows = min_rows + self._max_rows = max_rows + self._header_check_only = header_check_only + + # Store CSV-specific error details + self._encoding_error_detail = on_encoding_error_detail + self._column_error_detail = on_column_error_detail + self._row_error_detail = on_row_error_detail + self._parse_error_detail = on_parse_error_detail + + async def __call__(self, file: UploadFile = File(...), **kwargs: Any) -> StarletteUploadFile: + """ + FastAPI dependency entry point for CSV validation. + + Runs parent validations (size, type, filename) first, then + performs CSV-specific validations (encoding, structure). + Args: + file: The uploaded CSV file to validate. + Returns: + The validated UploadFile object. + Raises: + HTTPException: If validation fails. + """ + # Run all parent validations (size, content-type, filename) + # This will also rewind the file (await file.seek(0)) + try: + await super().__call__(file, **kwargs) + except ValidationError as e: + # Re-raise parent's validation error + self._raise_error(status_code=e.status_code, detail=str(e.detail)) + + # File is validated by parent and rewound. Start CSV checks. + try: + # Check encoding if specified + await self._validate_encoding(file) + await file.seek(0) # Rewind after encoding check + + # Check columns and row counts + await self._validate_csv_structure(file) + + except ValidationError as e: + await file.close() + self._raise_error(status_code=e.status_code, detail=str(e.detail)) + except Exception as e: + # Catch pandas errors (e.g., CParserError, UnicodeDecodeError) + await file.close() + detail = self._parse_error_detail or f"Failed to parse CSV file: {e}" + self._raise_error(status_code=400, detail=detail) + + # CRITICAL: Rewind the file AGAIN so the endpoint can read it. + await file.seek(0) + return file + + async def _validate_encoding(self, file: UploadFile) -> None: + """Checks if the file encoding matches one of the allowed encodings.""" + if not self._encoding: + return # No check needed + + # Read a small chunk to test encoding + chunk = await file.read(self._DEFAULT_CHUNK_SIZE) + if not chunk: + return # Empty file, let other validators handle + + valid_encoding_found = False + for enc in self._encoding: + try: + chunk.decode(enc) + valid_encoding_found = True + break # Found a valid one + except UnicodeDecodeError: + continue # Try next encoding + + if not valid_encoding_found: + detail = self._encoding_error_detail or ( + f"File encoding is not one of the allowed: {', '.join(self._encoding)}" + ) + raise ValidationError(detail=str(detail), status_code=400) + + def _check_columns(self, header: List[str]) -> None: + """Validates the CSV header against column rules. + Args: + header: List of column names from the CSV header. + Returns: + None + Raises: + ValidationError: If any column validation fails. + """ + header_set = set(header) + + # Exact columns (checks order and content) + if self._exact_columns: + if header != self._exact_columns: + detail = self._column_error_detail or ( + f"CSV header does not match exactly. " + f"Expected: {self._exact_columns}. Got: {header}" + ) + raise ValidationError(detail=str(detail), status_code=400) + return # If exact match is required, other checks are redundant + + # Required columns + if self._required_columns: + missing = self._required_columns - header_set + if missing: + detail = self._column_error_detail or ( + f"CSV is missing required columns: {sorted(list(missing))}" + ) + raise ValidationError(detail=str(detail), status_code=400) + + # Disallowed columns + if self._disallowed_columns: + found_disallowed = self._disallowed_columns.intersection(header_set) + if found_disallowed: + detail = self._column_error_detail or ( + f"CSV contains disallowed columns: {sorted(list(found_disallowed))}" + ) + raise ValidationError(detail=str(detail), status_code=400) + + def _check_row_counts(self, total_rows: int) -> None: + """Validates the total row count against min/max rules.""" + if self._min_rows is not None and total_rows < self._min_rows: + detail = self._row_error_detail or ( + f"File does not meet minimum required rows: {self._min_rows}. Found: {total_rows}." + ) + raise ValidationError(detail=str(detail), status_code=400) + + if self._max_rows is not None and total_rows > self._max_rows: + detail = self._row_error_detail or ( + f"File exceeds maximum allowed rows: {self._max_rows}. Found: {total_rows}." + ) + raise ValidationError(detail=str(detail), status_code=400) + + async def _validate_csv_structure(self, file: UploadFile) -> None: + """ + Validates the CSV columns and row counts using pandas. + + Uses either an efficient bounded read (header_check_only=True) + or a full stream (header_check_only=False) for row counts. + Args: + file: The uploaded CSV file to validate. + Returns: + None + Raises: + ValidationError: If any structure validation fails. + """ + # file.file is the underlying SpooledTemporaryFile + file_obj = file.file + + # Common pandas parameters + read_params = { + "delimiter": self._delimiter, + # Use the first encoding if specified, otherwise let pandas infer + "encoding": self._encoding[0] if self._encoding else None, + "on_bad_lines": "error", # Fail on malformed rows + } + + # Column Validation (always efficient) + column_check_needed = ( + self._required_columns or self._exact_columns or self._disallowed_columns + ) + + if column_check_needed: + try: + # Read *only* the header row + df_header = pd.read_csv(file_obj, nrows=0, **read_params) + await file.seek(0) # Rewind after header read + header_list = list(df_header.columns) + self._check_columns(header_list) + except ValidationError: + # Re-raise ValidationError from _check_columns + raise + except Exception as e: + # Catches pandas errors during header parse + raise ValidationError(detail=f"Failed to read CSV header: {e}", status_code=400) + + # Row Validation + row_check_needed = self._min_rows is not None or self._max_rows is not None + + # If no validation is needed, do a basic parse to ensure the file is valid CSV + if not row_check_needed and not column_check_needed: + try: + # Do a basic parse to catch malformed CSV (e.g., inconsistent field count) + # Read up to 1000 rows to validate format without consuming entire file + pd.read_csv(file_obj, nrows=1000, **read_params) + await file.seek(0) + except Exception as e: + raise ValidationError(detail=f"Failed to parse CSV file: {e}", status_code=400) + return # We are done + + if not row_check_needed: + return # We are done + + try: + # Full Read (header_check_only=False) + if not self._header_check_only: + total_rows = 0 + # Use chunksize for memory-efficient full read + for chunk_df in pd.read_csv(file_obj, chunksize=10_000, **read_params): + total_rows += len(chunk_df) + await file.seek(0) # Rewind after full read + self._check_row_counts(total_rows) + + # Efficient Bounded Read (header_check_only=True) + else: + # We read *just enough* rows to check bounds + rows_to_read = None + if self._max_rows is not None: + # Read max_rows + 1 data rows. + rows_to_read = self._max_rows + 1 + + if self._min_rows is not None: + # Ensure we read at least min_rows + rows_to_read = max(rows_to_read or 0, self._min_rows) + + # Read at most 'rows_to_read' + df_rows = pd.read_csv(file_obj, nrows=rows_to_read, **read_params) + await file.seek(0) # Rewind after bounded read + actual_rows = len(df_rows) + + # Check max_rows: If we read max_rows + 1, it's an error + if self._max_rows is not None and actual_rows > self._max_rows: + detail = self._row_error_detail or ( + f"File exceeds maximum allowed rows: {self._max_rows}." + ) + raise ValidationError(detail=str(detail), status_code=400) + + # Check min_rows: If we read N rows and got < min_rows, it's an error + if self._min_rows is not None and actual_rows < self._min_rows: + detail = self._row_error_detail or ( + f"File does not meet minimum required rows: {self._min_rows}." + ) + raise ValidationError(detail=str(detail), status_code=400) + + except ValidationError: + # Re-raise ValidationError from _check_row_counts + raise + except Exception as e: + # Catches pandas errors during row parse (e.g., ParserError) + if self._parse_error_detail: + detail_msg = ( + self._parse_error_detail(e) + if callable(self._parse_error_detail) + else self._parse_error_detail + ) + else: + detail_msg = f"Failed to parse CSV file: {e}" + raise ValidationError(detail=detail_msg, status_code=400) diff --git a/tests/test_csv_validator.py b/tests/test_csv_validator.py new file mode 100644 index 0000000..c0cd279 --- /dev/null +++ b/tests/test_csv_validator.py @@ -0,0 +1,320 @@ +""" +Professional test suite for the CSVValidator class. + +This suite uses pytest, pytest-asyncio, and a factory fixture to simulate +FastAPI UploadFile objects for validation. +""" + +import sys +import tempfile +from typing import Callable, Generator + +import pytest +from fastapi import UploadFile, HTTPException + +# --- Module under test --- +# (Adjust this import path based on your project structure) +# We assume the code is in: fastapi_assets/validators/csv_validator.py +from fastapi_assets.validators.csv_validator import CSVValidator + +# Mock pandas for the dependency test +# We set pd to None to simulate it not being installed +try: + import pandas as pd +except ImportError: + pd = None + + +# --- Fixtures --- + + +@pytest.fixture +def mock_upload_file_factory() -> Generator[Callable[..., UploadFile], None, None]: + """ + Provides a factory to create mock FastAPI UploadFile objects. + These are backed by SpooledTemporaryFile, just like FastAPI. + """ + # Keep track of files to close during cleanup + files_to_close = [] + + def _create_file( + content: str, + filename: str = "test.csv", + content_type: str = "text/csv", + encoding: str = "utf-8", + ) -> UploadFile: + """ + Creates a mock UploadFile object. + + Args: + content: The string content to write to the file. + filename: The name of the file. + content_type: The MIME type of the file. + encoding: The encoding to use for writing bytes. + + Returns: + A FastAPI UploadFile instance. + """ + # SpooledTemporaryFile is what FastAPI uses under the hood + file_obj = tempfile.SpooledTemporaryFile() + + # Write content as bytes + file_obj.write(content.encode(encoding)) + + # Rewind to the start so it can be read + file_obj.seek(0) + + # Create the UploadFile instance + upload_file = UploadFile( + file=file_obj, + filename=filename, + headers={"content-type": content_type}, + ) + files_to_close.append(file_obj) + return upload_file + + # Yield the factory function to the tests + yield _create_file + + # --- Teardown --- + # Close all files created by the factory + for f in files_to_close: + f.close() + + +# --- Test Cases --- + + +@pytest.mark.asyncio +class TestCSVValidator: + """Groups all tests for the CSVValidator.""" + + # --- Basic Success and File Handling --- + + async def test_happy_path_validation(self, mock_upload_file_factory: Callable[..., UploadFile]): + """ + Tests a valid CSV file that passes all checks. + """ + csv_content = "id,name,email\n1,Alice,a@b.com\n2,Bob,c@d.com" + validator = CSVValidator( + max_size="1MB", + required_columns=["id", "name"], + disallowed_columns=["password"], + min_rows=2, + max_rows=10, + ) + + file = mock_upload_file_factory(csv_content) + + # Should not raise any exception + validated_file = await validator(file) + assert validated_file is file + + async def test_file_is_rewound_after_validation( + self, mock_upload_file_factory: Callable[..., UploadFile] + ): + """ + Crucial test: Ensures the file is readable by the endpoint + after validation is complete. + """ + csv_content = "id,name\n1,Alice\n2,Bob" + validator = CSVValidator(required_columns=["id"], min_rows=2) + + file = mock_upload_file_factory(csv_content) + + # Run validation + await validator(file) + + # Check if the file pointer is at the beginning + assert await file.read() == csv_content.encode("utf-8") + + # --- Dependency Check --- + + def test_pandas_dependency_check(self, monkeypatch): + """ + Tests that CSVValidator raises an ImportError if pandas is not installed. + """ + # Temporarily simulate pandas not being installed + monkeypatch.setattr(sys.modules[__name__], "pd", None) + + # Reload the module to trigger the check at the top + # Note: This is tricky. A better way is to test the __init__ check. + # The user's code has the check in __init__, which is good. + + # Reset the 'pd' value in the validator module itself + monkeypatch.setattr("fastapi_assets.validators.csv_validator.pd", None) + + with pytest.raises(ImportError) as exc: + CSVValidator() + + assert "pandas" in str(exc.value).lower() + + # Restore pandas for other tests + monkeypatch.setattr("fastapi_assets.validators.csv_validator.pd", pd) + + # --- CSV-Specific Validations --- + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "encoding, content, should_pass", + [ + ("utf-8", "id,name\n1,Café", True), # Café has non-ASCII character (é) + ("ascii", "id,name\n1,Café", False), # Fails ascii check due to é + ("latin-1", "id,name\n1,Café", True), # Passes latin-1 + ], + ) + async def test_encoding_validation( + self, mock_upload_file_factory, encoding, content, should_pass + ): + """Tests the file encoding check.""" + if should_pass: + validator = CSVValidator(encoding=encoding) + file = mock_upload_file_factory(content, encoding=encoding) + await validator(file) + else: + # For the failure case, try to validate UTF-8 content with ASCII constraint + # Create UTF-8 file with non-ASCII character (é in Café) + file_fail = mock_upload_file_factory("id,name\n1,Café", encoding="utf-8") + # Validator expects ASCII only + validator_fail = CSVValidator(encoding="ascii") + with pytest.raises(HTTPException) as exc: + await validator_fail(file_fail) + assert "File encoding is not one of the allowed" in str(exc.value.detail) + + @pytest.mark.asyncio + async def test_delimiter_validation(self, mock_upload_file_factory): + """Tests the delimiter check.""" + csv_content = "id|name\n1|Alice" + + # Success case + validator_pass = CSVValidator(delimiter="|", required_columns=["id", "name"]) + await validator_pass(mock_upload_file_factory(csv_content)) + + # Fail case (default delimiter is ',') + validator_fail = CSVValidator(required_columns=["id", "name"]) + with pytest.raises(HTTPException) as exc: + # Pandas fails to find 'id' and 'name' + await validator_fail(mock_upload_file_factory(csv_content)) + assert "missing required columns" in str(exc.value.detail) + + @pytest.mark.asyncio + async def test_column_validations(self, mock_upload_file_factory): + """Tests required, exact, and disallowed columns.""" + csv_content = "id,name,email,secret_key\n1,Alice,a@b.com,xyz" + + # Test Required (Pass) + validator_req = CSVValidator(required_columns=["id", "email"]) + await validator_req(mock_upload_file_factory(csv_content)) + + # Test Required (Fail) + validator_req_fail = CSVValidator(required_columns=["id", "timestamp"]) + with pytest.raises(HTTPException) as exc: + await validator_req_fail(mock_upload_file_factory(csv_content)) + assert "missing required columns" in str(exc.value.detail) + assert "timestamp" in str(exc.value.detail) + + # Test Disallowed (Pass) + validator_dis = CSVValidator(disallowed_columns=["password"]) + await validator_dis(mock_upload_file_factory(csv_content)) + + # Test Disallowed (Fail) + validator_dis_fail = CSVValidator(disallowed_columns=["name", "secret_key"]) + with pytest.raises(HTTPException) as exc: + await validator_dis_fail(mock_upload_file_factory(csv_content)) + assert "contains disallowed columns" in str(exc.value.detail) + assert "secret_key" in str(exc.value.detail) + + # Test Exact (Fail - wrong order) + validator_ex_fail = CSVValidator(exact_columns=["id", "email", "name", "secret_key"]) + with pytest.raises(HTTPException) as exc: + await validator_ex_fail(mock_upload_file_factory(csv_content)) + assert "does not match exactly" in str(exc.value.detail) + + # Test Exact (Success) + validator_ex_pass = CSVValidator(exact_columns=["id", "name", "email", "secret_key"]) + await validator_ex_pass(mock_upload_file_factory(csv_content)) + + @pytest.mark.asyncio + @pytest.mark.parametrize("header_check_only", [True, False]) + async def test_row_count_validation(self, mock_upload_file_factory, header_check_only): + """ + Tests min_rows and max_rows for both efficient and full-read modes. + CSV content has 3 data rows. + """ + csv_content = "id,name\n1,Alice\n2,Bob\n3,Charlie" + + # Test min_rows (Fail) + validator_min = CSVValidator(min_rows=4, header_check_only=header_check_only) + with pytest.raises(HTTPException) as exc_min: + await validator_min(mock_upload_file_factory(csv_content)) + assert "minimum required rows" in str(exc_min.value.detail) + + # Test max_rows (Fail) + validator_max = CSVValidator(max_rows=2, header_check_only=header_check_only) + with pytest.raises(HTTPException) as exc_max: + await validator_max(mock_upload_file_factory(csv_content)) + assert "exceeds maximum allowed rows" in str(exc_max.value.detail) + + # Test success (in bounds) + validator_pass = CSVValidator(min_rows=3, max_rows=3, header_check_only=header_check_only) + await validator_pass(mock_upload_file_factory(csv_content)) + + # --- Error Handling and Custom Messages --- + + @pytest.mark.asyncio + async def test_csv_parsing_error(self, mock_upload_file_factory): + """Tests a malformed CSV that causes a pandas ParserError.""" + # Row 2 has an extra field + csv_content = "id,name\n1,Alice\n2,Bob,extra\n3,Charlie" + + validator = CSVValidator() + with pytest.raises(HTTPException) as exc: + await validator(mock_upload_file_factory(csv_content)) + + assert exc.value.status_code == 400 + assert "Failed to parse CSV file" in str(exc.value.detail) + # Check that the underlying pandas error is included + assert "expected 2 fields" in str(exc.value.detail).lower() + + @pytest.mark.asyncio + async def test_custom_error_messages(self, mock_upload_file_factory): + """Tests that custom error messages override defaults.""" + csv_content = "id,name,password\n1,Alice,xyz" + + # Custom column error + validator_col = CSVValidator( + disallowed_columns=["password"], + on_column_error_detail="Cannot upload file with password.", + ) + with pytest.raises(HTTPException) as exc_col: + await validator_col(mock_upload_file_factory(csv_content)) + assert exc_col.value.detail == "Cannot upload file with password." + + # Custom row error + validator_row = CSVValidator( + min_rows=5, + on_row_error_detail="File must have at least 5 data rows.", + ) + with pytest.raises(HTTPException) as exc_row: + await validator_row(mock_upload_file_factory(csv_content)) + assert exc_row.value.detail == "File must have at least 5 data rows." + + # --- Inherited Validation --- + + @pytest.mark.asyncio + async def test_inherited_max_size_validation(self, mock_upload_file_factory): + """ + Tests that the parent FileValidator's max_size check still works. + Note: This tests the streaming size check as file.size is None. + """ + csv_content = "id,name\n1,Alice\n2,Bob\n3,Charlie" # ~40 bytes + + validator = CSVValidator(max_size="20B") + file = mock_upload_file_factory(csv_content) + + with pytest.raises(HTTPException) as exc: + await validator(file) + + assert exc.value.status_code == 413 # 413 Payload Too Large + assert "exceeds" in str(exc.value.detail).lower() + assert "20B" in str(exc.value.detail) From d7d699885a9f6776d82c5c1d7c5916f7cfb83fa5 Mon Sep 17 00:00:00 2001 From: Mohammed-Saajid Date: Tue, 28 Oct 2025 17:57:07 +0530 Subject: [PATCH 15/30] ReFormatted --- fastapi_assets/validators/image_validator.py | 3 +- pyproject.toml | 176 +++++++++---------- tests/test_file_validator.py | 36 ++-- 3 files changed, 106 insertions(+), 109 deletions(-) diff --git a/fastapi_assets/validators/image_validator.py b/fastapi_assets/validators/image_validator.py index 5e43cfc..af0c59d 100644 --- a/fastapi_assets/validators/image_validator.py +++ b/fastapi_assets/validators/image_validator.py @@ -12,6 +12,7 @@ try: # Pillow is required for ImageValidator from PIL import Image, UnidentifiedImageError + PIL = True except ImportError: PIL = None # type: ignore @@ -96,7 +97,7 @@ def __init__( "The 'Pillow' library is required for ImageValidator. " "Install it with 'pip install fastapi-assets[image]'" ) - + # Set default image content types if not overridden if "content_types" not in kwargs: kwargs["content_types"] = _DEFAULT_IMAGE_CONTENT_TYPES diff --git a/pyproject.toml b/pyproject.toml index bc7b2c4..fc63280 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,88 +1,88 @@ -[project] -name = "fastapi-assets" -version = "0.1.0" -description = "FastAPI Assets - A validation and assertion toolkit for FastAPI file uploads and request metadata." -readme = "README.md" -requires-python = ">=3.12" -license = { file = "MIT" } -dependencies = ["fastapi>=0.119.1"] - -[project.optional-dependencies] -pandas = ["pandas>=2.3.3"] -image = ["pillow>=12.0.0"] -dev = [ - "mkdocs>=1.6.1", - "mkdocs-material>=9.6.22", - "mkdocstrings[python]>=0.30.1", - "mypy>=1.18.2", - "pytest>=8.4.2", - "pytest-asyncio>=1.2.0", - "ruff>=0.14.1", -] -optional = ["fastapi-assets[pandas,image]"] - -[tool.setuptools] -packages = ["fastapi_assets"] - -[tool.setuptools.package-data] -"fastapi_assets" = ["py.typed"] - - -# Pytest Configuration -[tool.pytest.ini_options] -minversion = "8.0" -addopts = "--maxfail=1 --disable-warnings" -testpaths = ["tests"] -python_files = ["test_*.py"] -python_classes = ["Test*"] -python_functions = ["test_*"] -markers = [ - "slow: marks tests as slow (deselect with '-m \"not slow\"')", - "integration: marks integration tests", -] - -# Ruff Configuration (Linting + Formatting) -[tool.ruff] -line-length = 100 -target-version = "py310" -extend-exclude = ["build", "dist", ".venv", "docs"] - -# Enable selected rule categories (see: https://docs.astral.sh/ruff/rules/) -[tool.ruff.lint] -select = [ - "E", # pycodestyle errors - "F", # pyflakes - "B", # bugbear - "I", # import sorting - "UP", # pyupgrade - "C4", # comprehensions - "RUF",# ruff-specific -] -ignore = [ - "E501", # Ignore line-length errors (handled by formatter) -] - -# Auto-formatting style (compatible with Black) -[tool.ruff.format] -quote-style = "double" -indent-style = "space" -line-ending = "lf" -skip-magic-trailing-comma = false - -# Import sorting behavior -[tool.ruff.lint.isort] -known-first-party = ["fastapi_assets"] -combine-as-imports = true - - -# Mypy Configuration (Static Type Checking) -[tool.mypy] -python_version = "3.12" -check_untyped_defs = true -disallow_untyped_defs = true -disallow_any_unimported = true -warn_unused_ignores = true -warn_redundant_casts = true -warn_unused_configs = true -strict_optional = true -ignore_missing_imports = true +[project] +name = "fastapi-assets" +version = "0.1.0" +description = "FastAPI Assets - A validation and assertion toolkit for FastAPI file uploads and request metadata." +readme = "README.md" +requires-python = ">=3.12" +license = { file = "MIT" } +dependencies = ["fastapi>=0.119.1"] + +[project.optional-dependencies] +pandas = ["pandas>=2.3.3"] +image = ["pillow>=12.0.0"] +dev = [ + "mkdocs>=1.6.1", + "mkdocs-material>=9.6.22", + "mkdocstrings[python]>=0.30.1", + "mypy>=1.18.2", + "pytest>=8.4.2", + "pytest-asyncio>=1.2.0", + "ruff>=0.14.1", +] +optional = ["fastapi-assets[pandas,image]"] + +[tool.setuptools] +packages = ["fastapi_assets"] + +[tool.setuptools.package-data] +"fastapi_assets" = ["py.typed"] + + +# Pytest Configuration +[tool.pytest.ini_options] +minversion = "8.0" +addopts = "--maxfail=1 --disable-warnings" +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +markers = [ + "slow: marks tests as slow (deselect with '-m \"not slow\"')", + "integration: marks integration tests", +] + +# Ruff Configuration (Linting + Formatting) +[tool.ruff] +line-length = 100 +target-version = "py310" +extend-exclude = ["build", "dist", ".venv", "docs"] + +# Enable selected rule categories (see: https://docs.astral.sh/ruff/rules/) +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "F", # pyflakes + "B", # bugbear + "I", # import sorting + "UP", # pyupgrade + "C4", # comprehensions + "RUF",# ruff-specific +] +ignore = [ + "E501", # Ignore line-length errors (handled by formatter) +] + +# Auto-formatting style (compatible with Black) +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +line-ending = "lf" +skip-magic-trailing-comma = false + +# Import sorting behavior +[tool.ruff.lint.isort] +known-first-party = ["fastapi_assets"] +combine-as-imports = true + + +# Mypy Configuration (Static Type Checking) +[tool.mypy] +python_version = "3.12" +check_untyped_defs = true +disallow_untyped_defs = true +disallow_any_unimported = true +warn_unused_ignores = true +warn_redundant_casts = true +warn_unused_configs = true +strict_optional = true +ignore_missing_imports = true diff --git a/tests/test_file_validator.py b/tests/test_file_validator.py index a64b36f..fb9b39c 100644 --- a/tests/test_file_validator.py +++ b/tests/test_file_validator.py @@ -26,19 +26,13 @@ def mock_upload_file() -> MagicMock: file.size = 1024 # 1KB # Default streaming mock (1024 bytes total) - file.read.side_effect = [ - b"a" * 512, - b"b" * 512, - b"", # End of file marker - ] - - # Mock the chunks() async generator - async def mock_chunks(size: int): - """Mock async generator for chunks""" - yield b"a" * 512 - yield b"b" * 512 - - file.chunks = mock_chunks + file.read = AsyncMock( + side_effect=[ + b"a" * 512, + b"b" * 512, + b"", # End of file marker + ] + ) return file @@ -172,13 +166,15 @@ async def test_call_streaming_size_too_large(self, mock_upload_file: MagicMock): # Mock file.size = None mock_upload_file.size = None - # Mock chunks to return 1500 bytes total - async def mock_chunks_too_large(size: int): - """Mock async generator that yields too much data""" - yield b"a" * 1000 - yield b"b" * 500 - - mock_upload_file.chunks = mock_chunks_too_large + # Mock read to return too much data (1500 bytes total) + # _DEFAULT_CHUNK_SIZE is 8192, so we return that plus more to exceed 1KB + mock_upload_file.read = AsyncMock( + side_effect=[ + b"a" * 1024, # First chunk: 1024 bytes (already exceeds 1KB limit) + b"b" * 500, # This won't be read because we'll fail on first chunk + b"", # End of file + ] + ) with pytest.raises(HTTPException) as exc_info: await validator(mock_upload_file) From 4294ab4fd1cd13f41a7113a4b54b5e31045985c5 Mon Sep 17 00:00:00 2001 From: GUGHAN-3001 Date: Fri, 31 Oct 2025 09:33:08 +0530 Subject: [PATCH 16/30] feat(validators): Add CookieAssert and fix CI --- fastapi_assets/validators/cookie_validator.py | 383 ++++++++++++++++++ pyproject.toml | 11 +- tests/test_cookie_validator.py | 214 ++++++++++ 3 files changed, 602 insertions(+), 6 deletions(-) create mode 100644 fastapi_assets/validators/cookie_validator.py create mode 100644 tests/test_cookie_validator.py diff --git a/fastapi_assets/validators/cookie_validator.py b/fastapi_assets/validators/cookie_validator.py new file mode 100644 index 0000000..df1b6f0 --- /dev/null +++ b/fastapi_assets/validators/cookie_validator.py @@ -0,0 +1,383 @@ +"""FastAPI cookie validation with reusable dependencies.""" + +import inspect +import re +from typing import Any, Callable, Dict, Optional, Union + +from fastapi import Request, status + +from fastapi_assets.core.base_validator import BaseValidator +from fastapi_assets.core.exceptions import ValidationError + + +# Pre-built regex patterns for the `format` parameter +PRE_BUILT_PATTERNS: Dict[str, str] = { + "session_id": r"^[A-Za-z0-9_-]{16,128}$", + "uuid4": r"^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[89abAB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$", + "bearer_token": r"^[Bb]earer [A-Za-z0-9\._~\+\/=-]+$", + "email": r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", + "datetime": r"^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|([+-]\d{2}:\d{2}))?$", +} + + +class CookieAssert(BaseValidator): + """ + A class-based dependency to validate FastAPI Cookies with granular control. + + This class is instantiated as a re-usable dependency that can be + injected into FastAPI endpoints using `Depends()`. It provides fine-grained + validation rules and specific error messages for each rule. + + Example: + ```python + from fastapi import FastAPI, Depends + + app = FastAPI() + + validate_session = CookieAssert( + alias="session-id", + format="uuid4", + on_required_error_detail="Invalid or missing session ID.", + on_pattern_error_detail="Session ID must be a valid UUIDv4." + ) + + @app.get("/items/") + async def read_items(session_id: str = Depends(validate_session)): + return {"session_id": session_id} + ``` + """ + + def __init__( + self, + *, + # --- Core Parameters --- + alias: str, + default: Any = ..., + required: Optional[bool] = None, + # --- Validation Rules --- + gt: Optional[float] = None, + ge: Optional[float] = None, + lt: Optional[float] = None, + le: Optional[float] = None, + min_length: Optional[int] = None, + max_length: Optional[int] = None, + regex: Optional[str] = None, + pattern: Optional[str] = None, + format: Optional[str] = None, + validator: Optional[Callable[[Any], bool]] = None, + # --- Granular Error Messages --- + on_required_error_detail: str = "Cookie is required.", + on_numeric_error_detail: str = "Cookie value must be a number.", + on_comparison_error_detail: str = "Cookie value fails comparison rules.", + on_length_error_detail: str = "Cookie value fails length constraints.", + on_pattern_error_detail: str = "Cookie has an invalid format.", + on_validator_error_detail: str = "Cookie failed custom validation.", + # --- Base Error --- + status_code: int = status.HTTP_400_BAD_REQUEST, + error_detail: str = "Cookie validation failed.", + ) -> None: + """ + Initializes the CookieAssert validator. + + Args: + alias (str): (Required) The exact, case-sensitive name of the + cookie (e.g., "session-id"). + default (Any): The default value to return if the cookie is not + present. If not set, `required` defaults to `True`. + required (Optional[bool]): Explicitly set to `True` or `False`. Overrides + `default` for determining if a cookie is required. + gt (Optional[float]): "Greater than" numeric comparison. + ge (Optional[float]): "Greater than or equal to" numeric comparison. + lt (Optional[float]): "Less than" numeric comparison. + le (Optional[float]): "Less than or equal to" numeric comparison. + min_length (Optional[int]): Minimum string length. + max_length (Optional[int]): Maximum string length. + regex (Optional[str]): Custom regex pattern. + pattern (Optional[str]): Alias for `regex`. + format (Optional[str]): A key from `PRE_BUILT_PATTERNS` (e.g., "uuid4"). + validator (Optional[Callable]): A custom validation function (sync or async). + on_required_error_detail (str): Error for missing required cookie. + on_numeric_error_detail (str): Error for float conversion failure. + on_comparison_error_detail (str): Error for gt/ge/lt/le failure. + on_length_error_detail (str): Error for min/max length failure. + on_pattern_error_detail (str): Error for regex/format failure. + on_validator_error_detail (str): Error for custom validator failure. + status_code (int): The default HTTP status code to raise on failure. + error_detail (str): A generic fallback error message. + + Raises: + ValueError: If `regex`/`pattern` and `format` are used simultaneously. + ValueError: If an unknown `format` key is provided. + """ + super().__init__(status_code=status_code, error_detail=error_detail) + + # --- Store Core Parameters --- + self.alias = alias + self.default = default + + # --- FIXED `is_required` logic --- + if required is not None: + self.is_required = required # Use explicit value if provided + else: + # Infer from default only if 'required' was not set + self.is_required = default is ... + + # --- Store Validation Rules --- + self.gt: Optional[float] = gt + self.ge: Optional[float] = ge + self.lt: Optional[float] = lt + self.le: Optional[float] = le + self.min_length: Optional[int] = min_length + self.max_length: Optional[int] = max_length + self.custom_validator: Optional[Callable[[Any], bool]] = validator + + # --- Store Error Messages --- + self.err_required: str = on_required_error_detail + self.err_numeric: str = on_numeric_error_detail + self.err_compare: str = on_comparison_error_detail + self.err_length: str = on_length_error_detail + self.err_pattern: str = on_pattern_error_detail + self.err_validator: str = on_validator_error_detail + + # --- Handle Regex/Pattern --- + self.final_regex_str: Optional[str] = regex or pattern + if self.final_regex_str and format: + raise ValueError( + "Cannot use 'regex'/'pattern' and 'format' simultaneously." + ) + if format: + if format not in PRE_BUILT_PATTERNS: + raise ValueError( + f"Unknown format: '{format}'. " + f"Available: {list(PRE_BUILT_PATTERNS.keys())}" + ) + self.final_regex_str = PRE_BUILT_PATTERNS[format] + + self.final_regex: Optional[re.Pattern[str]] = ( + re.compile(self.final_regex_str) + if self.final_regex_str + else None + ) + + def _validate_numeric(self, value: str) -> Optional[float]: + """ + Tries to convert value to float. Returns float or None. + + This check is only triggered if gt, ge, lt, or le are set. + + Raises: + ValidationError: If conversion to float fails. + """ + if any(v is not None for v in [self.gt, self.ge, self.lt, self.le]): + try: + return float(value) + except (ValueError, TypeError): + raise ValidationError( + detail=self.err_numeric, + status_code=status.HTTP_400_BAD_REQUEST, + ) + return None + + def _validate_comparison(self, value: float) -> None: + """ + Checks gt, ge, lt, le rules against a numeric value. + + Raises: + ValidationError: If any comparison fails. + """ + if self.gt is not None and not value > self.gt: + raise ValidationError( + detail=self.err_compare, + status_code=status.HTTP_400_BAD_REQUEST, + ) + if self.ge is not None and not value >= self.ge: + raise ValidationError( + detail=self.err_compare, + status_code=status.HTTP_400_BAD_REQUEST, + ) + if self.lt is not None and not value < self.lt: + raise ValidationError( + detail=self.err_compare, + status_code=status.HTTP_400_BAD_REQUEST, + ) + if self.le is not None and not value <= self.le: + raise ValidationError( + detail=self.err_compare, + status_code=status.HTTP_400_BAD_REQUEST, + ) + + def _validate_length(self, value: str) -> None: + """ + Checks min_length and max_length rules. + + Raises: + ValidationError: If length constraints fail. + """ + value_len = len(value) + if self.min_length is not None and value_len < self.min_length: + raise ValidationError( + detail=self.err_length, + status_code=status.HTTP_400_BAD_REQUEST, + ) + if self.max_length is not None and value_len > self.max_length: + raise ValidationError( + detail=self.err_length, + status_code=status.HTTP_400_BAD_REQUEST, + ) + + def _validate_pattern(self, value: str) -> None: + """ + Checks regex/format pattern rule. + + Raises: + ValidationError: If the regex pattern does not match. + """ + if self.final_regex and not self.final_regex.search(value): + raise ValidationError( + detail=self.err_pattern, + status_code=status.HTTP_400_BAD_REQUEST, + ) + + async def _validate_custom(self, value: str) -> None: + """ + Runs the custom validator function (sync or async). + + Raises: + ValidationError: If the function returns False or raises an Exception. + """ + if self.custom_validator: + try: + # Handle both sync and async validators + if inspect.iscoroutinefunction(self.custom_validator): + is_valid = await self.custom_validator(value) + else: + is_valid = self.custom_validator(value) + + if not is_valid: + raise ValidationError( + detail=self.err_validator, + status_code=status.HTTP_400_BAD_REQUEST, + ) + except ValidationError: + # Re-raise our own validation errors + raise + except Exception as e: + # Validator function raising an error is a validation failure + raise ValidationError( + detail=f"{self.err_validator}: {e}", + status_code=status.HTTP_400_BAD_REQUEST, + ) + + def _validate_logic( + self, cookie_value: Optional[str] + ) -> Union[float, str, None]: + """ + Pure validation logic (testable without FastAPI). + + This method runs all validation checks and can be tested + independently of FastAPI. + + Args: + cookie_value: The cookie value to validate. + + Returns: + Union[float, str, None]: The validated value (float if numeric, + str otherwise, or None if not required). + + Raises: + ValidationError: If any validation check fails. + """ + # 1. Check for required + if cookie_value is None: + if self.is_required: + raise ValidationError( + detail=self.err_required, + status_code=status.HTTP_400_BAD_REQUEST, + ) + return self.default if self.default is not ... else None + + # 2. Check numeric and comparison + numeric_value = self._validate_numeric(cookie_value) + if numeric_value is not None: + self._validate_comparison(numeric_value) + + # 3. Check length + self._validate_length(cookie_value) + + # 4. Check pattern + self._validate_pattern(cookie_value) + + # 5. Check custom validator (sync version for pure logic) + if self.custom_validator: + try: + if inspect.iscoroutinefunction(self.custom_validator): + # Can't await in sync context, async validators handled in __call__ + pass + else: + is_valid = self.custom_validator(cookie_value) + if not is_valid: + raise ValidationError( + detail=self.err_validator, + status_code=status.HTTP_400_BAD_REQUEST, + ) + except ValidationError: + raise + except Exception as e: + raise ValidationError( + detail=f"{self.err_validator}: {e}", + status_code=status.HTTP_400_BAD_REQUEST, + ) + + # Explicit return + return numeric_value if numeric_value is not None else cookie_value + + async def __call__(self, request: Request) -> Union[float, str, None]: + """ + FastAPI dependency entry point. + + This method is called by FastAPI's dependency injection system. + It retrieves the cookie from the request and runs all validation logic. + + Args: + request (Request): The incoming FastAPI request object. + + Raises: + HTTPException: If any validation fails, this is raised with + the specific status code and detail message. + + Returns: + Union[float, str, None]: The validated cookie value. This will be a + `float` if numeric comparisons were used, otherwise a `str`. + Returns `None` or the `default` value if not required and not present. + """ + try: + # Validate alias is set + if not self.alias: + raise ValidationError( + detail="Internal Server Error: `CookieAssert` must be " + "initialized with an `alias`.", + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + # Extract cookie value from request + cookie_value: Optional[str] = request.cookies.get(self.alias) + + # Run pure validation logic + result = self._validate_logic(cookie_value) + + # Run async custom validator if present + if ( + self.custom_validator + and inspect.iscoroutinefunction(self.custom_validator) + and cookie_value is not None + ): + await self._validate_custom(cookie_value) + + return result + + except ValidationError as e: + # Convert validation error to HTTP exception + self._raise_error(detail=e.detail, status_code=e.status_code) + # This line is never reached (after _raise_error always raises), + # but mypy needs to see it for type completeness + return None # pragma: no cover \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index fc63280..44fcf4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,8 @@ dev = [ "mypy>=1.18.2", "pytest>=8.4.2", "pytest-asyncio>=1.2.0", + "anyio>=4.0.0", + "httpx>=0.27.0", "ruff>=0.14.1", ] optional = ["fastapi-assets[pandas,image]"] @@ -27,18 +29,19 @@ packages = ["fastapi_assets"] [tool.setuptools.package-data] "fastapi_assets" = ["py.typed"] - # Pytest Configuration [tool.pytest.ini_options] minversion = "8.0" -addopts = "--maxfail=1 --disable-warnings" +addopts = "--maxfail=1 --disable-warnings" # <-- REMOVED -p no:asyncio testpaths = ["tests"] python_files = ["test_*.py"] python_classes = ["Test*"] python_functions = ["test_*"] +asyncio_mode = "auto" markers = [ "slow: marks tests as slow (deselect with '-m \"not slow\"')", "integration: marks integration tests", + "anyio: marks tests that use anyio", ] # Ruff Configuration (Linting + Formatting) @@ -47,7 +50,6 @@ line-length = 100 target-version = "py310" extend-exclude = ["build", "dist", ".venv", "docs"] -# Enable selected rule categories (see: https://docs.astral.sh/ruff/rules/) [tool.ruff.lint] select = [ "E", # pycodestyle errors @@ -62,19 +64,16 @@ ignore = [ "E501", # Ignore line-length errors (handled by formatter) ] -# Auto-formatting style (compatible with Black) [tool.ruff.format] quote-style = "double" indent-style = "space" line-ending = "lf" skip-magic-trailing-comma = false -# Import sorting behavior [tool.ruff.lint.isort] known-first-party = ["fastapi_assets"] combine-as-imports = true - # Mypy Configuration (Static Type Checking) [tool.mypy] python_version = "3.12" diff --git a/tests/test_cookie_validator.py b/tests/test_cookie_validator.py new file mode 100644 index 0000000..e0a7c56 --- /dev/null +++ b/tests/test_cookie_validator.py @@ -0,0 +1,214 @@ +""" +Unit Tests for the CookieAssert Validator +========================================= + +This file contains unit tests for the `CookieAssert` class. +It uses `pytest` and `httpx` to create a test FastAPI application +and send requests to it to validate all behaviors. + +This version is modified to use 'pytest-anyio'. + +To run these tests: +1. Make sure `cookie_validator.py` (the main code) is in the same directory. +2. pip install pytest httpx fastapi "uvicorn[standard]" pytest-anyio +3. Run `pytest -v` in your terminal. +""" + +import pytest +import uuid +from typing import Optional +from fastapi import FastAPI, Depends, status +from httpx import AsyncClient, ASGITransport # <-- FIXED: Added ASGITransport + +# Import the class to be tested +# (Assumes cookie_validator.py is in the same directory) +try: + from fastapi_assets.validators.cookie_validator import CookieAssert, ValidationError, BaseValidator +except ImportError: + # This skip allows the test runner to at least start + pytest.skip("Could not import CookieAssert from cookie_validator.py", allow_module_level=True) + +# --- Test Application Setup --- + +# Define validators once, as they would be in a real app +validate_required_uuid = CookieAssert( + alias="session-id", + format="uuid4", + on_required_error_detail="Session is required.", + on_pattern_error_detail="Invalid session format." +) + +validate_optional_gt10 = CookieAssert( + alias="tracker", + required=False, # Explicitly set to False + default=None, # Provide a default + gt=10, + on_comparison_error_detail="Tracker must be > 10.", + on_numeric_error_detail="Tracker must be a number." +) + +validate_length_5 = CookieAssert( + alias="code", + min_length=5, + max_length=5, + on_length_error_detail="Code must be 5 chars." +) + +def _custom_check(val: str): + """A sample custom validator function""" + if val not in ["admin", "user"]: + raise ValueError("Role is invalid") + return True + +validate_custom_role = CookieAssert( + alias="role", + validator=_custom_check, + on_validator_error_detail="Invalid role." +) + +# Create a minimal FastAPI app for testing +app = FastAPI() + +@app.get("/test-required") +async def get_required(session: str = Depends(validate_required_uuid)): + """Test endpoint for a required, formatted cookie.""" + return {"session": session} + +@app.get("/test-optional") +async def get_optional(tracker: Optional[float] = Depends(validate_optional_gt10)): + """Test endpoint for an optional, numeric cookie.""" + # Note: numeric validators return floats + return {"tracker": tracker} + +@app.get("/test-length") +async def get_length(code: str = Depends(validate_length_5)): + """Test endpoint for a length-constrained cookie.""" + return {"code": code} + +@app.get("/test-custom") +async def get_custom(role: str = Depends(validate_custom_role)): + """Test endpoint for a custom-validated cookie.""" + return {"role": role} + +# --- Pytest Fixtures --- + +@pytest.fixture(scope="module") +def anyio_backend(): + """ + This is the FIX. + Tells pytest-anyio to use the 'asyncio' backend for these tests. + """ + return "asyncio" + + +@pytest.fixture(scope="module") +async def client(anyio_backend): + """ + Pytest fixture to create an AsyncClient for the test app. + Depends on the 'anyio_backend' fixture. + + FIXED: Use ASGITransport instead of app parameter + """ + async with AsyncClient( + transport=ASGITransport(app=app), # <-- FIXED: Wrap app with ASGITransport + base_url="http://test" + ) as ac: + yield ac + +# --- Test Cases --- + +@pytest.mark.anyio # Use 'anyio' marker +async def test_required_cookie_missing(client: AsyncClient): + """Tests that a required cookie raises an error if missing.""" + response = await client.get("/test-required") + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"detail": "Session is required."} + +@pytest.mark.anyio +async def test_required_cookie_invalid_format(client: AsyncClient): + """Tests that a required cookie fails on invalid format.""" + cookies = {"session-id": "not-a-valid-uuid"} + response = await client.get("/test-required", cookies=cookies) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"detail": "Invalid session format."} + +@pytest.mark.anyio +async def test_required_cookie_valid(client: AsyncClient): + """Tests that a required cookie passes with valid format.""" + valid_uuid = str(uuid.uuid4()) + cookies = {"session-id": valid_uuid} + response = await client.get("/test-required", cookies=cookies) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"session": valid_uuid} + +@pytest.mark.anyio +async def test_optional_cookie_missing(client: AsyncClient): + """Tests that an optional cookie returns the default (None) if missing.""" + response = await client.get("/test-optional") + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"tracker": None} + +@pytest.mark.anyio +async def test_optional_cookie_invalid_comparison(client: AsyncClient): + """Tests that an optional cookie fails numeric comparison.""" + cookies = {"tracker": "5"} # 5 is not > 10 + response = await client.get("/test-optional", cookies=cookies) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"detail": "Tracker must be > 10."} + +@pytest.mark.anyio +async def test_optional_cookie_invalid_numeric(client: AsyncClient): + """Tests that a numeric cookie fails non-numeric values.""" + cookies = {"tracker": "not-a-number"} + response = await client.get("/test-optional", cookies=cookies) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"detail": "Tracker must be a number."} + +@pytest.mark.anyio +async def test_optional_cookie_valid(client: AsyncClient): + """Tests that an optional cookie passes with a valid value.""" + cookies = {"tracker": "100"} + response = await client.get("/test-optional", cookies=cookies) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"tracker": 100.0} # Note: value is cast to float + +@pytest.mark.anyio +async def test_length_cookie_too_short(client: AsyncClient): + """Tests min_length validation.""" + cookies = {"code": "1234"} # Length 4, min is 5 + response = await client.get("/test-length", cookies=cookies) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"detail": "Code must be 5 chars."} + +@pytest.mark.anyio +async def test_length_cookie_too_long(client: AsyncClient): + """Tests max_length validation.""" + cookies = {"code": "123456"} # Length 6, max is 5 + response = await client.get("/test-length", cookies=cookies) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"detail": "Code must be 5 chars."} + +@pytest.mark.anyio +async def test_length_cookie_valid(client: AsyncClient): + """Tests valid length validation.""" + cookies = {"code": "12345"} + response = await client.get("/test-length", cookies=cookies) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"code": "12345"} + +@pytest.mark.anyio +async def test_custom_validator_fail(client: AsyncClient): + """Tests custom validator function failure.""" + cookies = {"role": "guest"} # "guest" is not in ["admin", "user"] + response = await client.get("/test-custom", cookies=cookies) + assert response.status_code == status.HTTP_400_BAD_REQUEST + # Note: custom validator exceptions are appended to the detail + assert response.json() == {"detail": "Invalid role.: Role is invalid"} + +@pytest.mark.anyio +async def test_custom_validator_pass(client: AsyncClient): + """Tests custom validator function success.""" + cookies = {"role": "admin"} + response = await client.get("/test-custom", cookies=cookies) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"role": "admin"} From 1b959f7943794ee9f1338291dde76b2d413c7853 Mon Sep 17 00:00:00 2001 From: sriharishb Date: Sun, 2 Nov 2025 01:30:26 +0530 Subject: [PATCH 17/30] Added PathAssert with Tests --- .../request_validators/path_assert.py | 292 ++++++++++++++++++ tests/test_path_assert.py | 261 ++++++++++++++++ 2 files changed, 553 insertions(+) create mode 100644 fastapi_assets/request_validators/path_assert.py create mode 100644 tests/test_path_assert.py diff --git a/fastapi_assets/request_validators/path_assert.py b/fastapi_assets/request_validators/path_assert.py new file mode 100644 index 0000000..4f2b5f8 --- /dev/null +++ b/fastapi_assets/request_validators/path_assert.py @@ -0,0 +1,292 @@ +"""Module providing the PathValidator for validating path parameters in FastAPI.""" +import re +from typing import Any, Callable, List, Optional, Union +from fastapi import Path +from fastapi_assets.core.base_validator import BaseValidator, ValidationError + + +class PathValidator(BaseValidator): + r""" + A general-purpose dependency for validating path parameters in FastAPI. + + It validates path parameters with additional constraints like allowed values, + regex patterns, string length checks, numeric bounds, and custom validators. + + .. code-block:: python + from fastapi import FastAPI + from fastapi_assets.validators.path_validator import PathValidator + + app = FastAPI() + + # Create reusable validators + item_id_validator = PathValidator( + gt=0, + lt=1000, + on_error_detail="Item ID must be between 1 and 999" + ) + + username_validator = PathValidator( + min_length=5, + max_length=15, + pattern=r"^[a-zA-Z0-9]+$", + on_error_detail="Username must be 5-15 alphanumeric characters" + ) + + @app.get("/items/{item_id}") + def get_item(item_id: int = item_id_validator): + return {"item_id": item_id} + + @app.get("/users/{username}") + def get_user(username: str = username_validator): + return {"username": username} + """ + + def __init__( + self, + default: Any = ..., + *, + allowed_values: Optional[List[Any]] = None, + pattern: Optional[str] = None, + min_length: Optional[int] = None, + max_length: Optional[int] = None, + gt: Optional[Union[int, float]] = None, + lt: Optional[Union[int, float]] = None, + ge: Optional[Union[int, float]] = None, + le: Optional[Union[int, float]] = None, + validator: Optional[Callable[[Any], bool]] = None, + on_error_detail: Optional[Union[str, Callable[[Any], str]]] = None, + # Standard Path() parameters + title: Optional[str] = None, + description: Optional[str] = None, + alias: Optional[str] = None, + deprecated: Optional[bool] = None, + **path_kwargs + ): + """ + Initializes the PathValidator. + + Args: + default: Default value for the path parameter (usually ... for required). + allowed_values: List of allowed values for the parameter. + pattern: Regex pattern the parameter must match (for strings). + min_length: Minimum length for string parameters. + max_length: Maximum length for string parameters. + gt: Value must be greater than this (for numeric parameters). + lt: Value must be less than this (for numeric parameters). + ge: Value must be greater than or equal to this. + le: Value must be less than or equal to this. + validator: Custom validation function that takes the value and returns bool. + on_error_detail: Custom error message for validation failures. + title: Title for API documentation. + description: Description for API documentation. + alias: Alternative parameter name. + deprecated: Whether the parameter is deprecated. + **path_kwargs: Additional arguments passed to FastAPI's Path(). + """ + # Call super() with default error handling + super().__init__( + status_code=400, + error_detail=on_error_detail or "Path parameter validation failed." + ) + + # Store validation rules + self._allowed_values = allowed_values + self._pattern = re.compile(pattern) if pattern else None + self._min_length = min_length + self._max_length = max_length + self._gt = gt + self._lt = lt + self._ge = ge + self._le = le + self._custom_validator = validator + + # Store the underlying FastAPI Path parameter + # This preserves all standard Path() features (title, description, etc.) + self._path_param = Path( + default, + title=title, + description=description, + alias=alias, + deprecated=deprecated, + gt=gt, + lt=lt, + ge=ge, + le=le, + **path_kwargs + ) + + def __call__(self, value: Any = None) -> Any: + """ + FastAPI dependency entry point for path validation. + + Args: + value: The path parameter value extracted from the URL. + + Returns: + The validated path parameter value. + + Raises: + HTTPException: If validation fails. + """ + # If value is None, it means FastAPI will inject the actual path parameter + # This happens because FastAPI handles the Path() dependency internally + if value is None: + # Return a dependency that FastAPI will use + async def dependency(param_value: Any = self._path_param): + return self._validate(param_value) + return dependency + + # If value is provided (for testing), validate directly + return self._validate(value) + + def _validate(self, value: Any) -> Any: + """ + Runs all validation checks on the parameter value. + + Args: + value: The path parameter value to validate. + + Returns: + The validated value. + + Raises: + HTTPException: If any validation check fails. + """ + try: + self._validate_allowed_values(value) + self._validate_pattern(value) + self._validate_length(value) + self._validate_numeric_bounds(value) + self._validate_custom(value) + except ValidationError as e: + # Convert ValidationError to HTTPException + self._raise_error( + value=value, + status_code=e.status_code, + detail=str(e.detail) + ) + + return value + + def _validate_allowed_values(self, value: Any) -> None: + """ + Checks if the value is in the list of allowed values. + + Args: + value: The parameter value to check. + + Raises: + ValidationError: If the value is not in allowed_values. + """ + if self._allowed_values is None: + return # No validation rule set + + if value not in self._allowed_values: + detail = ( + f"Value '{value}' is not allowed. " + f"Allowed values are: {', '.join(map(str, self._allowed_values))}" + ) + raise ValidationError(detail=detail, status_code=400) + + def _validate_pattern(self, value: Any) -> None: + """ + Checks if the string value matches the required regex pattern. + + Args: + value: The parameter value to check. + + Raises: + ValidationError: If the value doesn't match the pattern. + """ + if self._pattern is None: + return # No validation rule set + + if not isinstance(value, str): + return # Pattern validation only applies to strings + + if not self._pattern.match(value): + detail = ( + f"Value '{value}' does not match the required pattern: " + f"{self._pattern.pattern}" + ) + raise ValidationError(detail=detail, status_code=400) + + def _validate_length(self, value: Any) -> None: + """ + Checks if the string length is within the specified bounds. + + Args: + value: The parameter value to check. + + Raises: + ValidationError: If the length is out of bounds. + """ + if not isinstance(value, str): + return # Length validation only applies to strings + + value_len = len(value) + + if self._min_length is not None and value_len < self._min_length: + detail = ( + f"Value '{value}' is too short. " + f"Minimum length is {self._min_length} characters." + ) + raise ValidationError(detail=detail, status_code=400) + + if self._max_length is not None and value_len > self._max_length: + detail = ( + f"Value '{value}' is too long. " + f"Maximum length is {self._max_length} characters." + ) + raise ValidationError(detail=detail, status_code=400) + + def _validate_numeric_bounds(self, value: Any) -> None: + """ + Checks if numeric values satisfy gt, lt, ge, le constraints. + + Args: + value: The parameter value to check. + + Raises: + ValidationError: If the value is out of the specified bounds. + """ + if not isinstance(value, (int, float)): + return # Numeric validation only applies to numbers + + if self._gt is not None and value <= self._gt: + detail = f"Value must be greater than {self._gt}" + raise ValidationError(detail=detail, status_code=400) + + if self._lt is not None and value >= self._lt: + detail = f"Value must be less than {self._lt}" + raise ValidationError(detail=detail, status_code=400) + + if self._ge is not None and value < self._ge: + detail = f"Value must be greater than or equal to {self._ge}" + raise ValidationError(detail=detail, status_code=400) + + if self._le is not None and value > self._le: + detail = f"Value must be less than or equal to {self._le}" + raise ValidationError(detail=detail, status_code=400) + + def _validate_custom(self, value: Any) -> None: + """ + Runs a custom validation function if provided. + + Args: + value: The parameter value to check. + + Raises: + ValidationError: If the custom validator returns False or raises an exception. + """ + if self._custom_validator is None: + return # No custom validator set + + try: + if not self._custom_validator(value): + detail = f"Custom validation failed for value '{value}'" + raise ValidationError(detail=detail, status_code=400) + except Exception as e: + # If the validator itself raises an exception, catch it + detail = f"Custom validation error: {str(e)}" + raise ValidationError(detail=detail, status_code=400) \ No newline at end of file diff --git a/tests/test_path_assert.py b/tests/test_path_assert.py new file mode 100644 index 0000000..8d1acd7 --- /dev/null +++ b/tests/test_path_assert.py @@ -0,0 +1,261 @@ +""" +tests for the PathValidator class. +""" +from fastapi import HTTPException +import pytest +from fastapi_assets.core.base_validator import ValidationError +from fastapi_assets.request_validators.path_assert import PathValidator + +# Fixtures for common PathValidator configurations +@pytest.fixture +def base_validator(): + """Returns a basic PathValidator with no rules.""" + return PathValidator() + +@pytest.fixture +def numeric_validator(): + """Returns a PathValidator configured for numeric validation.""" + return PathValidator(gt=0, lt=1000) + +@pytest.fixture +def string_validator(): + """Returns a PathValidator configured for string validation.""" + return PathValidator( + min_length=3, + max_length=15, + pattern=r"^[a-zA-Z0-9_]+$" + ) + +@pytest.fixture +def allowed_values_validator(): + """Returns a PathValidator with allowed values.""" + return PathValidator( + allowed_values=["active", "inactive", "pending"] + ) + +# Test class for constructor __init__ behavior +class TestPathValidatorInit: + def test_init_defaults(self): + """Tests that all validation rules are None by default.""" + validator = PathValidator() + assert validator._allowed_values is None + assert validator._pattern is None + assert validator._min_length is None + assert validator._max_length is None + assert validator._gt is None + assert validator._lt is None + assert validator._ge is None + assert validator._le is None + assert validator._custom_validator is None + + def test_init_allowed_values(self): + """Tests that allowed_values are stored correctly.""" + values = ["active", "inactive"] + validator = PathValidator(allowed_values=values) + assert validator._allowed_values == values + + def test_init_pattern_compilation(self): + """Tests that regex pattern is compiled.""" + pattern = r"^[a-z0-9]+$" + validator = PathValidator(pattern=pattern) + assert validator._pattern is not None + assert validator._pattern.pattern == pattern + + def test_init_numeric_bounds(self): + """Tests that numeric bounds are stored correctly.""" + validator = PathValidator(gt=0, lt=100, ge=1, le=99) + assert validator._gt == 0 + assert validator._lt == 100 + assert validator._ge == 1 + assert validator._le == 99 + + def test_init_length_bounds(self): + """Tests that length bounds are stored correctly.""" + validator = PathValidator(min_length=5, max_length=20) + assert validator._min_length == 5 + assert validator._max_length == 20 + + def test_init_custom_error_detail(self): + """Tests that custom error messages are stored.""" + custom_error = "Invalid path parameter" + validator = PathValidator(on_error_detail=custom_error) + # _error_detail attribute holds error message + assert validator._error_detail == custom_error or custom_error in str(validator.__dict__) + + def test_init_custom_validator_function(self): + """Tests that custom validator function is stored.""" + def is_even(x): return x % 2 == 0 + validator = PathValidator(validator=is_even) + # Validate custom function works + assert validator._custom_validator(4) is True + assert validator._custom_validator(3) is False + + def test_init_fastapi_path_creation(self): + """Tests that internal FastAPI Path object is created.""" + validator = PathValidator( + title="Item ID", + description="The unique identifier", + gt=0, + lt=1000 + ) + assert validator._path_param is not None + + def test_init_combined_rules(self): + """Tests initialization with multiple combined rules.""" + validator = PathValidator( + min_length=3, + max_length=20, + pattern=r"^[a-zA-Z]+$", + title="Category", + description="Product category slug" + ) + assert validator._min_length == 3 + assert validator._max_length == 20 + assert validator._pattern is not None + +# Validation method tests +class TestPathValidatorValidateAllowedValues: + def test_allowed_values_no_rule(self, base_validator): + """Validation should pass if no rule is set.""" + try: + base_validator._validate_allowed_values("any_value") + except ValidationError: + pytest.fail("Validation failed when no rule was set.") + + def test_allowed_values_valid(self, allowed_values_validator): + """Test valid allowed value.""" + try: + allowed_values_validator._validate_allowed_values("active") + except ValidationError: + pytest.fail("Failed on valid allowed value.") + + def test_allowed_values_invalid(self, allowed_values_validator): + """Test invalid allowed value raises ValidationError.""" + with pytest.raises(ValidationError): + allowed_values_validator._validate_allowed_values("deleted") + +class TestPathValidatorValidatePattern: + def test_pattern_no_rule(self, base_validator): + """Validation passes when no pattern rule.""" + try: + base_validator._validate_pattern("anything@123!@#") + except ValidationError: + pytest.fail("Validation failed when no pattern rule.") + + def test_pattern_valid_match(self, string_validator): + """Valid pattern match.""" + try: + string_validator._validate_pattern("user_123") + except ValidationError: + pytest.fail("Validation failed on valid pattern.") + + def test_pattern_invalid_match(self, string_validator): + """Invalid pattern raises ValidationError.""" + with pytest.raises(ValidationError): + string_validator._validate_pattern("user@123") + + def test_pattern_non_string_ignored(self, string_validator): + """Skip pattern validation for non-strings.""" + try: + string_validator._validate_pattern(123) + except ValidationError: + pytest.fail("Pattern validation should not apply to non-strings.") + + def test_pattern_email_like(self): + """Email pattern with valid and invalid cases.""" + validator = PathValidator(pattern=r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$") + try: + validator._validate_pattern("user.name+tag@example.com") + except ValidationError: + pytest.fail("Valid email-like pattern failed") + with pytest.raises(ValidationError): + validator._validate_pattern("user@domain") # missing TLD + +# Length validation tests +class TestPathValidatorValidateLength: + def test_length_no_rule(self, base_validator): + """Validation passes when no length rule.""" + try: + base_validator._validate_length("x") + base_validator._validate_length("longer") + except ValidationError: + pytest.fail("Failed no length rule.") + + def test_length_valid_within_bounds(self, string_validator): + """Valid length within bounds.""" + try: + string_validator._validate_length("hello") + except ValidationError: + pytest.fail("Failed valid length.") + + def test_length_too_short(self, string_validator): + """Fails if shorter than min_length.""" + with pytest.raises(ValidationError): + string_validator._validate_length("ab") + + def test_length_too_long(self, string_validator): + """Fails if longer than max_length.""" + with pytest.raises(ValidationError): + string_validator._validate_length("a"*20) + +# Numeric bounds validation +class TestPathValidatorValidateNumericBounds: + def test_no_rule(self, base_validator): + try: + base_validator._validate_numeric_bounds(999) + base_validator._validate_numeric_bounds(-999) + except ValidationError: + pytest.fail("Failed no numeric rule.") + + def test_gt_lt(self, numeric_validator): + try: + numeric_validator._validate_numeric_bounds(1) + numeric_validator._validate_numeric_bounds(999) + except ValidationError: + pytest.fail("Failed valid bounds.") + with pytest.raises(ValidationError): + numeric_validator._validate_numeric_bounds(0) + + def test_ge_le(self): + validator = PathValidator(ge=0, le=10) + try: + validator._validate_numeric_bounds(0) + validator._validate_numeric_bounds(10) + except ValidationError: + pytest.fail("Failed boundary values.") + with pytest.raises(ValidationError): + validator._validate_numeric_bounds(-1) + +# Custom validation tests +class TestPathValidatorValidateCustom: + def test_no_custom_validator(self, base_validator): + try: + base_validator._validate_custom("test") + except ValidationError: + pytest.fail("Failed with no custom validator.") + def test_valid_custom(self): + def is_even(x): return x % 2 == 0 + v = PathValidator(validator=is_even) + try: + v._validate_custom(4) + except ValidationError: + pytest.fail("Valid custom validation failed.") + def test_invalid_custom(self): + def is_even(x): return x % 2 == 0 + v = PathValidator(validator=is_even) + with pytest.raises(ValidationError): + v._validate_custom(3) + +# Integration of multiple validations +class TestPathValidatorIntegration: + def test_combined_valid(self): + v = PathValidator(allowed_values=["ok"], pattern=r"^ok$", min_length=2, max_length=2) + try: + v._validate("ok") + except ValidationError: + pytest.fail("Valid data failed validation.") + + def test_fail_in_combined(self): + v = PathValidator(allowed_values=["ok"], pattern=r"^ok$", min_length=2, max_length=2) + with pytest.raises(HTTPException): + v._validate("no") From 6980ff7eb3d07632bb8760fa0e2717e75766ce6e Mon Sep 17 00:00:00 2001 From: sriharishb Date: Sun, 2 Nov 2025 01:43:42 +0530 Subject: [PATCH 18/30] Added type annotations to fix mypy errors --- fastapi_assets/request_validators/path_assert.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/fastapi_assets/request_validators/path_assert.py b/fastapi_assets/request_validators/path_assert.py index 4f2b5f8..30a2155 100644 --- a/fastapi_assets/request_validators/path_assert.py +++ b/fastapi_assets/request_validators/path_assert.py @@ -60,8 +60,8 @@ def __init__( description: Optional[str] = None, alias: Optional[str] = None, deprecated: Optional[bool] = None, - **path_kwargs - ): + **path_kwargs : Any + ) -> None: """ Initializes the PathValidator. @@ -132,7 +132,7 @@ def __call__(self, value: Any = None) -> Any: # This happens because FastAPI handles the Path() dependency internally if value is None: # Return a dependency that FastAPI will use - async def dependency(param_value: Any = self._path_param): + async def dependency(param_value: Any = self._path_param) -> Any: return self._validate(param_value) return dependency From 618483058bd1b2321eb0f43f4682b87dfe9cab09 Mon Sep 17 00:00:00 2001 From: sriharishb Date: Mon, 3 Nov 2025 01:59:34 +0530 Subject: [PATCH 19/30] Implemented PathValidator and HeaderValidator with Tests --- .../{path_assert.py => path_validator.py} | 3 +- fastapi_assets/validators/header_validator.py | 275 +++++++++++++ tests/test_header_validator.py | 373 ++++++++++++++++++ ..._path_assert.py => test_path_validator.py} | 4 +- 4 files changed, 651 insertions(+), 4 deletions(-) rename fastapi_assets/request_validators/{path_assert.py => path_validator.py} (99%) create mode 100644 fastapi_assets/validators/header_validator.py create mode 100644 tests/test_header_validator.py rename tests/{test_path_assert.py => test_path_validator.py} (98%) diff --git a/fastapi_assets/request_validators/path_assert.py b/fastapi_assets/request_validators/path_validator.py similarity index 99% rename from fastapi_assets/request_validators/path_assert.py rename to fastapi_assets/request_validators/path_validator.py index 30a2155..954b239 100644 --- a/fastapi_assets/request_validators/path_assert.py +++ b/fastapi_assets/request_validators/path_validator.py @@ -14,7 +14,7 @@ class PathValidator(BaseValidator): .. code-block:: python from fastapi import FastAPI - from fastapi_assets.validators.path_validator import PathValidator + from fastapi_assets.path_validator import PathValidator app = FastAPI() @@ -161,7 +161,6 @@ def _validate(self, value: Any) -> Any: except ValidationError as e: # Convert ValidationError to HTTPException self._raise_error( - value=value, status_code=e.status_code, detail=str(e.detail) ) diff --git a/fastapi_assets/validators/header_validator.py b/fastapi_assets/validators/header_validator.py new file mode 100644 index 0000000..df7de14 --- /dev/null +++ b/fastapi_assets/validators/header_validator.py @@ -0,0 +1,275 @@ +"""HeaderValidator for validating HTTP headers in FastAPI.""" +import re +from typing import Any, Callable, Dict, List, Optional, Union, Pattern +from fastapi_assets.core.base_validator import BaseValidator, ValidationError +from fastapi import Header + + +# Predefined format patterns for common header validation use cases +_FORMAT_PATTERNS: Dict[str, str] = { + "uuid4": r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", + "email": r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", + "bearer_token": r"^Bearer [a-zA-Z0-9\-._~+/]+=*$", + "datetime": r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$", + "alphanumeric": r"^[a-zA-Z0-9]+$", + "api_key": r"^[a-zA-Z0-9]{32,}$", +} + + +class HeaderValidator(BaseValidator): + r""" + A general-purpose dependency for validating HTTP request headers in FastAPI. + + It extends FastAPI's built-in Header with additional validation capabilities + including pattern matching, format validation, allowed values, and custom validators. + + .. code-block:: python + from fastapi import FastAPI + from fastapi_assets.validators.header_validator import HeaderValidator + + app = FastAPI() + + # Validate API key header with pattern + api_key_validator = HeaderValidator( + alias="X-API-Key", + pattern=r"^[a-zA-Z0-9]{32}$", + required=True, + on_error_detail="Invalid API key format" + ) + + # Validate authorization header with bearer token format + auth_validator = HeaderValidator( + alias="Authorization", + format="bearer_token", + required=True + ) + + # Validate custom header with allowed values + version_validator = HeaderValidator( + alias="X-API-Version", + allowed_values=["v1", "v2", "v3"], + required=False, + default="v1" + ) + + @app.get("/secure") + def secure_endpoint( + api_key: str = api_key_validator, + auth: str = auth_validator, + version: str = version_validator + ): + return {"message": "Access granted", "version": version} + """ + + def __init__( + self, + default: Any = ..., + *, + alias: Optional[str] = None, + convert_underscores: bool = True, + pattern: Optional[str] = None, + format: Optional[str] = None, + allowed_values: Optional[List[str]] = None, + validator: Optional[Callable[[str], bool]] = None, + required: Optional[bool] = None, + on_error_detail: Optional[Union[str, Callable[[Any], str]]] = None, + title: Optional[str] = None, + description: Optional[str] = None, + **header_kwargs: Any + ) -> None: + # Call super() with default error handling + super().__init__( + status_code=400, + error_detail=on_error_detail or "Header validation failed." + ) + + # Determine if header is required + if required is None: + self._required = default is ... + else: + self._required = required + + # Store validation rules + self._allowed_values = allowed_values + self._custom_validator = validator + + # Define type hints for attributes + self._pattern: Optional[Pattern[str]] = None + self._format_name: Optional[str] = None + + # Handle pattern and format keys + if pattern and format: + raise ValueError("Cannot specify both 'pattern' and 'format'. Choose one.") + + if format: + if format not in _FORMAT_PATTERNS: + raise ValueError( + f"Unknown format '{format}'. " + f"Available formats: {', '.join(_FORMAT_PATTERNS.keys())}" + ) + self._pattern = re.compile(_FORMAT_PATTERNS[format], re.IGNORECASE) + self._format_name = format + elif pattern: + self._pattern = re.compile(pattern) + self._format_name = None + else: + self._pattern = None + self._format_name = None + + # Store the underlying FastAPI Header parameter + self._header_param = Header( + default, + alias=alias, + convert_underscores=convert_underscores, + title=title, + description=description, + **header_kwargs + ) + + # Store custom error detail + self._on_error_detail = on_error_detail + def __call__(self, header_value: Optional[str] = None) -> Any: + """ + FastAPI dependency entry point for header validation. + + Args: + header_value: The header value extracted from the request. + + Returns: + The validated header value. + + Raises: + HTTPException: If validation fails. + """ + # If value is None, return a dependency that FastAPI will use + if header_value is None: + def dependency(value: Optional[str] = self._header_param) -> Optional[str]: + return self._validate(value) + return dependency + + # If value is provided (for testing), validate directly + return self._validate(header_value) + + def _validate(self, value: Optional[str]) -> Optional[str]: + """ + Runs all validation checks on the header value. + + Args: + value: The header value to validate. + + Returns: + The validated value. + + Raises: + HTTPException: If any validation check fails. + """ + try: + self._validate_required(value) + except ValidationError as e: + self._raise_error( + value= value, + status_code=e.status_code, + detail=str(e.detail) + ) + if value is None or value == "": + return value or "" + try: + self._validate_allowed_values(value) + self._validate_pattern(value) + self._validate_custom(value) + + except ValidationError as e: + # Convert ValidationError to HTTPException + self._raise_error( + value= value, + status_code=e.status_code, + detail=str(e.detail) + ) + + return value + + def _validate_required(self, value: Optional[str]) -> None: + """ + Checks if the header is present when required. + + Args: + value: The header value to check. + + Raises: + ValidationError: If the header is required but missing. + """ + if self._required and (value is None or value == ""): + detail = self._on_error_detail or "Required header is missing." + if callable(detail): + detail_str = detail(value) + else: + detail_str = str(detail) + + raise ValidationError(detail=detail_str, status_code=400) + + def _validate_allowed_values(self, value: str) -> None: + """ + Checks if the value is in the list of allowed values. + + Args: + value: The header value to check. + + Raises: + ValidationError: If the value is not in allowed_values. + """ + if self._allowed_values is None: + return # No validation rule set + + if value not in self._allowed_values: + detail = ( + f"Header value '{value}' is not allowed. " + f"Allowed values are: {', '.join(self._allowed_values)}" + ) + raise ValidationError(detail=detail, status_code=400) + + def _validate_pattern(self, value: str) -> None: + """ + Checks if the header value matches the required regex pattern. + + Args: + value: The header value to check. + + Raises: + ValidationError: If the value doesn't match the pattern. + """ + if self._pattern is None: + return # No validation rule set + + if not self._pattern.match(value): + if self._format_name: + detail = ( + f"Header value does not match the required format: '{self._format_name}'" + ) + else: + detail = ( + f"Header value '{value}' does not match the required pattern: " + f"{self._pattern.pattern}" + ) + raise ValidationError(detail=detail, status_code=400) + + def _validate_custom(self, value: str) -> None: + """ + Runs a custom validation function if provided. + + Args: + value: The header value to check. + + Raises: + ValidationError: If the custom validator returns False or raises an exception. + """ + if self._custom_validator is None: + return # No custom validator set + + try: + if not self._custom_validator(value): + detail = f"Custom validation failed for header value '{value}'" + raise ValidationError(detail=detail, status_code=400) + except Exception as e: + # If the validator itself raises an exception, catch it + detail = f"Custom validation error: {str(e)}" + raise ValidationError(detail=detail, status_code=400) \ No newline at end of file diff --git a/tests/test_header_validator.py b/tests/test_header_validator.py new file mode 100644 index 0000000..b8b7f21 --- /dev/null +++ b/tests/test_header_validator.py @@ -0,0 +1,373 @@ +""" +Tests for the HeaderValidator class. +""" + +import pytest +from fastapi import HTTPException +from fastapi_assets.core.base_validator import ValidationError +from fastapi_assets.validators.header_validator import HeaderValidator + + +# --- Fixtures --- + +@pytest.fixture +def base_validator(): + """Returns a basic HeaderValidator with no rules.""" + return HeaderValidator() + + +@pytest.fixture +def required_validator(): + """Returns a HeaderValidator with required=True.""" + return HeaderValidator(required=True) + + +@pytest.fixture +def pattern_validator(): + """Returns a HeaderValidator with pattern validation.""" + return HeaderValidator(pattern=r"^[a-zA-Z0-9]{32}$") + + +@pytest.fixture +def format_validator(): + """Returns a HeaderValidator with bearer_token format.""" + return HeaderValidator(format="bearer_token") + + +@pytest.fixture +def allowed_values_validator(): + """Returns a HeaderValidator with allowed values.""" + return HeaderValidator(allowed_values=["v1", "v2", "v3"]) + + +@pytest.fixture +def custom_validator_obj(): + """Returns a HeaderValidator with custom validator function.""" + def is_even_length(val: str) -> bool: + return len(val) % 2 == 0 + + return HeaderValidator(validator=is_even_length) + + +# --- Test Classes --- + +class TestHeaderValidatorInit: + """Tests for the HeaderValidator's __init__ method.""" + + def test_init_defaults(self): + """Tests that all validation rules are None by default.""" + validator = HeaderValidator() + assert validator._allowed_values is None + assert validator._pattern is None + assert validator._custom_validator is None + assert validator._format_name is None + + def test_init_required_true(self): + """Tests that required flag is stored correctly.""" + validator = HeaderValidator(required=True) + assert validator._required is True + + def test_init_required_false(self): + """Tests that required can be set to False.""" + validator = HeaderValidator(required=False, default="default_value") + assert validator._required is False + + def test_init_pattern_compilation(self): + """Tests that pattern is compiled to regex.""" + pattern = r"^[A-Z0-9]+$" + validator = HeaderValidator(pattern=pattern) + assert validator._pattern is not None + assert validator._pattern.pattern == pattern + + def test_init_format_uuid4(self): + """Tests that format='uuid4' is recognized.""" + validator = HeaderValidator(format="uuid4") + assert validator._format_name == "uuid4" + assert validator._pattern is not None + + def test_init_format_email(self): + """Tests that format='email' is recognized.""" + validator = HeaderValidator(format="email") + assert validator._format_name == "email" + assert validator._pattern is not None + + def test_init_format_bearer_token(self): + """Tests that format='bearer_token' is recognized.""" + validator = HeaderValidator(format="bearer_token") + assert validator._format_name == "bearer_token" + assert validator._pattern is not None + + def test_init_invalid_format(self): + """Tests that invalid format raises ValueError.""" + with pytest.raises(ValueError, match="Unknown format"): + HeaderValidator(format="invalid_format") + + def test_init_pattern_and_format_conflict(self): + """Tests that both pattern and format cannot be specified.""" + with pytest.raises(ValueError, match="Cannot specify both"): + HeaderValidator(pattern=r"^test$", format="uuid4") + + def test_init_allowed_values(self): + """Tests that allowed values are stored correctly.""" + values = ["alpha", "beta", "gamma"] + validator = HeaderValidator(allowed_values=values) + assert validator._allowed_values == values + + def test_init_custom_validator_function(self): + """Tests that custom validator function is stored.""" + def is_positive(val: str) -> bool: + return val.startswith("+") + + validator = HeaderValidator(validator=is_positive) + assert validator._custom_validator is not None + assert validator._custom_validator("+test") is True + assert validator._custom_validator("-test") is False + + def test_init_custom_error_detail(self): + """Tests that custom error detail is stored.""" + custom_msg = "Invalid header value" + validator = HeaderValidator(on_error_detail=custom_msg) + assert validator._on_error_detail == custom_msg + + def test_init_alias(self): + """Tests that alias for header name is set.""" + validator = HeaderValidator(alias="X-API-Key") + assert validator._header_param is not None + + +class TestHeaderValidatorValidateRequired: + """Tests for the _validate_required method.""" + + def test_required_with_value(self, required_validator): + """Tests required validation passes when value is present.""" + try: + required_validator._validate_required("some_value") + except ValidationError: + pytest.fail("Required validation failed with valid value") + + def test_required_missing_value(self, required_validator): + """Tests required validation fails when value is None.""" + with pytest.raises(ValidationError) as e: + required_validator._validate_required(None) + + assert e.value.status_code == 400 + assert "missing" in e.value.detail.lower() + + def test_required_empty_string(self, required_validator): + """Tests required validation fails with empty string.""" + with pytest.raises(ValidationError): + required_validator._validate_required("") + + def test_not_required_with_none(self, base_validator): + """Tests validation passes when not required and value is None.""" + base_validator._required = False + try: + base_validator._validate_required(None) + except ValidationError: + pytest.fail("Non-required validation should pass with None") + + +class TestHeaderValidatorValidateAllowedValues: + """Tests for the _validate_allowed_values method.""" + + def test_allowed_values_no_rule(self, base_validator): + """Tests that no validation happens when no allowed_values rule.""" + try: + base_validator._validate_allowed_values("any_value") + except ValidationError: + pytest.fail("Validation failed with no rule set") + + def test_allowed_values_valid(self, allowed_values_validator): + """Tests allowed value passes validation.""" + try: + allowed_values_validator._validate_allowed_values("v1") + except ValidationError: + pytest.fail("Valid allowed value failed") + + def test_allowed_values_invalid(self, allowed_values_validator): + """Tests invalid allowed value raises error.""" + with pytest.raises(ValidationError) as e: + allowed_values_validator._validate_allowed_values("v4") + + assert e.value.status_code == 400 + assert "not allowed" in e.value.detail.lower() + + def test_allowed_values_all_options(self, allowed_values_validator): + """Tests all allowed values individually.""" + for value in ["v1", "v2", "v3"]: + try: + allowed_values_validator._validate_allowed_values(value) + except ValidationError: + pytest.fail(f"Valid allowed value '{value}' failed") + + def test_allowed_values_case_sensitive(self, allowed_values_validator): + """Tests that allowed values are case-sensitive.""" + with pytest.raises(ValidationError): + allowed_values_validator._validate_allowed_values("V1") + + +class TestHeaderValidatorValidatePattern: + """Tests for the _validate_pattern method.""" + + def test_pattern_no_rule(self, base_validator): + """Tests validation passes with no pattern rule.""" + try: + base_validator._validate_pattern("anything") + except ValidationError: + pytest.fail("Validation failed with no pattern rule") + + def test_pattern_valid_match(self, pattern_validator): + """Tests pattern matches valid value.""" + try: + pattern_validator._validate_pattern("abcdefghijklmnopqrstuvwxyz123456") + except ValidationError: + pytest.fail("Valid pattern match failed") + + def test_pattern_invalid_match(self, pattern_validator): + """Tests pattern fails on invalid value.""" + with pytest.raises(ValidationError) as e: + pattern_validator._validate_pattern("short") + + assert e.value.status_code == 400 + assert "does not match" in e.value.detail.lower() + + def test_pattern_format_uuid4_valid(self): + """Tests uuid4 format validation passes.""" + validator = HeaderValidator(format="uuid4") + valid_uuid = "550e8400-e29b-41d4-a716-446655440000" + try: + validator._validate_pattern(valid_uuid) + except ValidationError: + pytest.fail("Valid UUID4 failed") + + def test_pattern_format_uuid4_invalid(self): + """Tests uuid4 format validation fails.""" + validator = HeaderValidator(format="uuid4") + with pytest.raises(ValidationError) as e: + validator._validate_pattern("not-a-uuid") + + assert "format" in e.value.detail.lower() + + def test_pattern_format_bearer_token_valid(self, format_validator): + """Tests bearer token format validation passes.""" + try: + format_validator._validate_pattern("Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9") + except ValidationError: + pytest.fail("Valid bearer token failed") + + def test_pattern_format_bearer_token_invalid(self, format_validator): + """Tests bearer token format validation fails.""" + with pytest.raises(ValidationError): + format_validator._validate_pattern("InvalidToken") + + def test_pattern_format_email_valid(self): + """Tests email format validation passes.""" + validator = HeaderValidator(format="email") + try: + validator._validate_pattern("user@example.com") + except ValidationError: + pytest.fail("Valid email failed") + + def test_pattern_format_email_invalid(self): + """Tests email format validation fails.""" + validator = HeaderValidator(format="email") + with pytest.raises(ValidationError): + validator._validate_pattern("not-an-email") + + +class TestHeaderValidatorValidateCustom: + """Tests for the _validate_custom method.""" + + def test_custom_no_validator(self, base_validator): + """Tests validation passes with no custom validator.""" + try: + base_validator._validate_custom("any_value") + except ValidationError: + pytest.fail("Validation failed with no custom validator") + + def test_custom_validator_valid(self, custom_validator_obj): + """Tests custom validator passes on valid input.""" + try: + custom_validator_obj._validate_custom("even") # 4 chars + except ValidationError: + pytest.fail("Valid custom validation failed") + + def test_custom_validator_invalid(self, custom_validator_obj): + """Tests custom validator fails on invalid input.""" + with pytest.raises(ValidationError) as e: + custom_validator_obj._validate_custom("odd") # 3 chars + + assert e.value.status_code == 400 + # Accept either failure message depending on your validator code + assert ( + "custom validation failed" in e.value.detail.lower() + or "custom validation error" in e.value.detail.lower() + ) + + + def test_custom_validator_exception(self): + """Tests custom validator exception is caught.""" + def buggy_validator(val: str) -> bool: + raise ValueError("Unexpected error") + + validator = HeaderValidator(validator=buggy_validator) + with pytest.raises(ValidationError) as e: + validator._validate_custom("test") + + assert "custom validation error" in e.value.detail.lower() + + +class TestHeaderValidatorValidate: + """Tests for the main _validate method.""" + + def test_validate_valid_header(self): + """Tests full validation pipeline with valid header.""" + validator = HeaderValidator( + required=True, + allowed_values=["api", "web"], + pattern=r"^[a-z]+$" + ) + try: + result = validator._validate("api") + assert result == "api" + except ValidationError: + pytest.fail("Valid header failed validation") + + def test_validate_fails_required(self): + """Tests validation fails on required check.""" + validator = HeaderValidator(required=True) + with pytest.raises(HTTPException): + validator._validate(None) + + def test_validate_fails_allowed_values(self): + """Tests validation fails on allowed values check.""" + validator = HeaderValidator(allowed_values=["good"]) + with pytest.raises(HTTPException): + validator._validate("bad") + + def test_validate_fails_pattern(self): + """Tests validation fails on pattern check.""" + validator = HeaderValidator(pattern=r"^[0-9]+$") + with pytest.raises(HTTPException): + validator._validate("abc") + + def test_validate_fails_custom(self): + """Tests validation fails on custom validator.""" + def no_spaces(val: str) -> bool: + return " " not in val + + validator = HeaderValidator(validator=no_spaces) + with pytest.raises(HTTPException): + validator._validate("has space") + + def test_validate_empty_optional_header(self): + """Tests optional header with empty string passes.""" + validator = HeaderValidator(required=False) + result = validator._validate("") + assert result == "" + + def test_validate_none_optional_header(self): + """Tests optional header with None passes.""" + validator = HeaderValidator(required=False) + result = validator._validate(None) + assert result is None or result == "" \ No newline at end of file diff --git a/tests/test_path_assert.py b/tests/test_path_validator.py similarity index 98% rename from tests/test_path_assert.py rename to tests/test_path_validator.py index 8d1acd7..e4a31a5 100644 --- a/tests/test_path_assert.py +++ b/tests/test_path_validator.py @@ -4,7 +4,7 @@ from fastapi import HTTPException import pytest from fastapi_assets.core.base_validator import ValidationError -from fastapi_assets.request_validators.path_assert import PathValidator +from fastapi_assets.request_validators.path_validator import PathValidator # Fixtures for common PathValidator configurations @pytest.fixture @@ -80,7 +80,7 @@ def test_init_custom_error_detail(self): custom_error = "Invalid path parameter" validator = PathValidator(on_error_detail=custom_error) # _error_detail attribute holds error message - assert validator._error_detail == custom_error or custom_error in str(validator.__dict__) + assert validator.error_detail == custom_error or custom_error in str(validator.__dict__) def test_init_custom_validator_function(self): """Tests that custom validator function is stored.""" From 182e537a8afe49cd1ce9c9bcb2259cb9deae0720 Mon Sep 17 00:00:00 2001 From: Mohammed-Saajid Date: Mon, 3 Nov 2025 09:48:04 +0530 Subject: [PATCH 20/30] Relocated Files --- .../header_validator.py | 0 fastapi_assets/validators/cookie_validator.py | 383 ------------------ tests/test_cookie_validator.py | 214 ---------- tests/test_header_validator.py | 2 +- 4 files changed, 1 insertion(+), 598 deletions(-) rename fastapi_assets/{validators => request_validators}/header_validator.py (100%) delete mode 100644 fastapi_assets/validators/cookie_validator.py delete mode 100644 tests/test_cookie_validator.py diff --git a/fastapi_assets/validators/header_validator.py b/fastapi_assets/request_validators/header_validator.py similarity index 100% rename from fastapi_assets/validators/header_validator.py rename to fastapi_assets/request_validators/header_validator.py diff --git a/fastapi_assets/validators/cookie_validator.py b/fastapi_assets/validators/cookie_validator.py deleted file mode 100644 index df1b6f0..0000000 --- a/fastapi_assets/validators/cookie_validator.py +++ /dev/null @@ -1,383 +0,0 @@ -"""FastAPI cookie validation with reusable dependencies.""" - -import inspect -import re -from typing import Any, Callable, Dict, Optional, Union - -from fastapi import Request, status - -from fastapi_assets.core.base_validator import BaseValidator -from fastapi_assets.core.exceptions import ValidationError - - -# Pre-built regex patterns for the `format` parameter -PRE_BUILT_PATTERNS: Dict[str, str] = { - "session_id": r"^[A-Za-z0-9_-]{16,128}$", - "uuid4": r"^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[89abAB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$", - "bearer_token": r"^[Bb]earer [A-Za-z0-9\._~\+\/=-]+$", - "email": r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", - "datetime": r"^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|([+-]\d{2}:\d{2}))?$", -} - - -class CookieAssert(BaseValidator): - """ - A class-based dependency to validate FastAPI Cookies with granular control. - - This class is instantiated as a re-usable dependency that can be - injected into FastAPI endpoints using `Depends()`. It provides fine-grained - validation rules and specific error messages for each rule. - - Example: - ```python - from fastapi import FastAPI, Depends - - app = FastAPI() - - validate_session = CookieAssert( - alias="session-id", - format="uuid4", - on_required_error_detail="Invalid or missing session ID.", - on_pattern_error_detail="Session ID must be a valid UUIDv4." - ) - - @app.get("/items/") - async def read_items(session_id: str = Depends(validate_session)): - return {"session_id": session_id} - ``` - """ - - def __init__( - self, - *, - # --- Core Parameters --- - alias: str, - default: Any = ..., - required: Optional[bool] = None, - # --- Validation Rules --- - gt: Optional[float] = None, - ge: Optional[float] = None, - lt: Optional[float] = None, - le: Optional[float] = None, - min_length: Optional[int] = None, - max_length: Optional[int] = None, - regex: Optional[str] = None, - pattern: Optional[str] = None, - format: Optional[str] = None, - validator: Optional[Callable[[Any], bool]] = None, - # --- Granular Error Messages --- - on_required_error_detail: str = "Cookie is required.", - on_numeric_error_detail: str = "Cookie value must be a number.", - on_comparison_error_detail: str = "Cookie value fails comparison rules.", - on_length_error_detail: str = "Cookie value fails length constraints.", - on_pattern_error_detail: str = "Cookie has an invalid format.", - on_validator_error_detail: str = "Cookie failed custom validation.", - # --- Base Error --- - status_code: int = status.HTTP_400_BAD_REQUEST, - error_detail: str = "Cookie validation failed.", - ) -> None: - """ - Initializes the CookieAssert validator. - - Args: - alias (str): (Required) The exact, case-sensitive name of the - cookie (e.g., "session-id"). - default (Any): The default value to return if the cookie is not - present. If not set, `required` defaults to `True`. - required (Optional[bool]): Explicitly set to `True` or `False`. Overrides - `default` for determining if a cookie is required. - gt (Optional[float]): "Greater than" numeric comparison. - ge (Optional[float]): "Greater than or equal to" numeric comparison. - lt (Optional[float]): "Less than" numeric comparison. - le (Optional[float]): "Less than or equal to" numeric comparison. - min_length (Optional[int]): Minimum string length. - max_length (Optional[int]): Maximum string length. - regex (Optional[str]): Custom regex pattern. - pattern (Optional[str]): Alias for `regex`. - format (Optional[str]): A key from `PRE_BUILT_PATTERNS` (e.g., "uuid4"). - validator (Optional[Callable]): A custom validation function (sync or async). - on_required_error_detail (str): Error for missing required cookie. - on_numeric_error_detail (str): Error for float conversion failure. - on_comparison_error_detail (str): Error for gt/ge/lt/le failure. - on_length_error_detail (str): Error for min/max length failure. - on_pattern_error_detail (str): Error for regex/format failure. - on_validator_error_detail (str): Error for custom validator failure. - status_code (int): The default HTTP status code to raise on failure. - error_detail (str): A generic fallback error message. - - Raises: - ValueError: If `regex`/`pattern` and `format` are used simultaneously. - ValueError: If an unknown `format` key is provided. - """ - super().__init__(status_code=status_code, error_detail=error_detail) - - # --- Store Core Parameters --- - self.alias = alias - self.default = default - - # --- FIXED `is_required` logic --- - if required is not None: - self.is_required = required # Use explicit value if provided - else: - # Infer from default only if 'required' was not set - self.is_required = default is ... - - # --- Store Validation Rules --- - self.gt: Optional[float] = gt - self.ge: Optional[float] = ge - self.lt: Optional[float] = lt - self.le: Optional[float] = le - self.min_length: Optional[int] = min_length - self.max_length: Optional[int] = max_length - self.custom_validator: Optional[Callable[[Any], bool]] = validator - - # --- Store Error Messages --- - self.err_required: str = on_required_error_detail - self.err_numeric: str = on_numeric_error_detail - self.err_compare: str = on_comparison_error_detail - self.err_length: str = on_length_error_detail - self.err_pattern: str = on_pattern_error_detail - self.err_validator: str = on_validator_error_detail - - # --- Handle Regex/Pattern --- - self.final_regex_str: Optional[str] = regex or pattern - if self.final_regex_str and format: - raise ValueError( - "Cannot use 'regex'/'pattern' and 'format' simultaneously." - ) - if format: - if format not in PRE_BUILT_PATTERNS: - raise ValueError( - f"Unknown format: '{format}'. " - f"Available: {list(PRE_BUILT_PATTERNS.keys())}" - ) - self.final_regex_str = PRE_BUILT_PATTERNS[format] - - self.final_regex: Optional[re.Pattern[str]] = ( - re.compile(self.final_regex_str) - if self.final_regex_str - else None - ) - - def _validate_numeric(self, value: str) -> Optional[float]: - """ - Tries to convert value to float. Returns float or None. - - This check is only triggered if gt, ge, lt, or le are set. - - Raises: - ValidationError: If conversion to float fails. - """ - if any(v is not None for v in [self.gt, self.ge, self.lt, self.le]): - try: - return float(value) - except (ValueError, TypeError): - raise ValidationError( - detail=self.err_numeric, - status_code=status.HTTP_400_BAD_REQUEST, - ) - return None - - def _validate_comparison(self, value: float) -> None: - """ - Checks gt, ge, lt, le rules against a numeric value. - - Raises: - ValidationError: If any comparison fails. - """ - if self.gt is not None and not value > self.gt: - raise ValidationError( - detail=self.err_compare, - status_code=status.HTTP_400_BAD_REQUEST, - ) - if self.ge is not None and not value >= self.ge: - raise ValidationError( - detail=self.err_compare, - status_code=status.HTTP_400_BAD_REQUEST, - ) - if self.lt is not None and not value < self.lt: - raise ValidationError( - detail=self.err_compare, - status_code=status.HTTP_400_BAD_REQUEST, - ) - if self.le is not None and not value <= self.le: - raise ValidationError( - detail=self.err_compare, - status_code=status.HTTP_400_BAD_REQUEST, - ) - - def _validate_length(self, value: str) -> None: - """ - Checks min_length and max_length rules. - - Raises: - ValidationError: If length constraints fail. - """ - value_len = len(value) - if self.min_length is not None and value_len < self.min_length: - raise ValidationError( - detail=self.err_length, - status_code=status.HTTP_400_BAD_REQUEST, - ) - if self.max_length is not None and value_len > self.max_length: - raise ValidationError( - detail=self.err_length, - status_code=status.HTTP_400_BAD_REQUEST, - ) - - def _validate_pattern(self, value: str) -> None: - """ - Checks regex/format pattern rule. - - Raises: - ValidationError: If the regex pattern does not match. - """ - if self.final_regex and not self.final_regex.search(value): - raise ValidationError( - detail=self.err_pattern, - status_code=status.HTTP_400_BAD_REQUEST, - ) - - async def _validate_custom(self, value: str) -> None: - """ - Runs the custom validator function (sync or async). - - Raises: - ValidationError: If the function returns False or raises an Exception. - """ - if self.custom_validator: - try: - # Handle both sync and async validators - if inspect.iscoroutinefunction(self.custom_validator): - is_valid = await self.custom_validator(value) - else: - is_valid = self.custom_validator(value) - - if not is_valid: - raise ValidationError( - detail=self.err_validator, - status_code=status.HTTP_400_BAD_REQUEST, - ) - except ValidationError: - # Re-raise our own validation errors - raise - except Exception as e: - # Validator function raising an error is a validation failure - raise ValidationError( - detail=f"{self.err_validator}: {e}", - status_code=status.HTTP_400_BAD_REQUEST, - ) - - def _validate_logic( - self, cookie_value: Optional[str] - ) -> Union[float, str, None]: - """ - Pure validation logic (testable without FastAPI). - - This method runs all validation checks and can be tested - independently of FastAPI. - - Args: - cookie_value: The cookie value to validate. - - Returns: - Union[float, str, None]: The validated value (float if numeric, - str otherwise, or None if not required). - - Raises: - ValidationError: If any validation check fails. - """ - # 1. Check for required - if cookie_value is None: - if self.is_required: - raise ValidationError( - detail=self.err_required, - status_code=status.HTTP_400_BAD_REQUEST, - ) - return self.default if self.default is not ... else None - - # 2. Check numeric and comparison - numeric_value = self._validate_numeric(cookie_value) - if numeric_value is not None: - self._validate_comparison(numeric_value) - - # 3. Check length - self._validate_length(cookie_value) - - # 4. Check pattern - self._validate_pattern(cookie_value) - - # 5. Check custom validator (sync version for pure logic) - if self.custom_validator: - try: - if inspect.iscoroutinefunction(self.custom_validator): - # Can't await in sync context, async validators handled in __call__ - pass - else: - is_valid = self.custom_validator(cookie_value) - if not is_valid: - raise ValidationError( - detail=self.err_validator, - status_code=status.HTTP_400_BAD_REQUEST, - ) - except ValidationError: - raise - except Exception as e: - raise ValidationError( - detail=f"{self.err_validator}: {e}", - status_code=status.HTTP_400_BAD_REQUEST, - ) - - # Explicit return - return numeric_value if numeric_value is not None else cookie_value - - async def __call__(self, request: Request) -> Union[float, str, None]: - """ - FastAPI dependency entry point. - - This method is called by FastAPI's dependency injection system. - It retrieves the cookie from the request and runs all validation logic. - - Args: - request (Request): The incoming FastAPI request object. - - Raises: - HTTPException: If any validation fails, this is raised with - the specific status code and detail message. - - Returns: - Union[float, str, None]: The validated cookie value. This will be a - `float` if numeric comparisons were used, otherwise a `str`. - Returns `None` or the `default` value if not required and not present. - """ - try: - # Validate alias is set - if not self.alias: - raise ValidationError( - detail="Internal Server Error: `CookieAssert` must be " - "initialized with an `alias`.", - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) - - # Extract cookie value from request - cookie_value: Optional[str] = request.cookies.get(self.alias) - - # Run pure validation logic - result = self._validate_logic(cookie_value) - - # Run async custom validator if present - if ( - self.custom_validator - and inspect.iscoroutinefunction(self.custom_validator) - and cookie_value is not None - ): - await self._validate_custom(cookie_value) - - return result - - except ValidationError as e: - # Convert validation error to HTTP exception - self._raise_error(detail=e.detail, status_code=e.status_code) - # This line is never reached (after _raise_error always raises), - # but mypy needs to see it for type completeness - return None # pragma: no cover \ No newline at end of file diff --git a/tests/test_cookie_validator.py b/tests/test_cookie_validator.py deleted file mode 100644 index e0a7c56..0000000 --- a/tests/test_cookie_validator.py +++ /dev/null @@ -1,214 +0,0 @@ -""" -Unit Tests for the CookieAssert Validator -========================================= - -This file contains unit tests for the `CookieAssert` class. -It uses `pytest` and `httpx` to create a test FastAPI application -and send requests to it to validate all behaviors. - -This version is modified to use 'pytest-anyio'. - -To run these tests: -1. Make sure `cookie_validator.py` (the main code) is in the same directory. -2. pip install pytest httpx fastapi "uvicorn[standard]" pytest-anyio -3. Run `pytest -v` in your terminal. -""" - -import pytest -import uuid -from typing import Optional -from fastapi import FastAPI, Depends, status -from httpx import AsyncClient, ASGITransport # <-- FIXED: Added ASGITransport - -# Import the class to be tested -# (Assumes cookie_validator.py is in the same directory) -try: - from fastapi_assets.validators.cookie_validator import CookieAssert, ValidationError, BaseValidator -except ImportError: - # This skip allows the test runner to at least start - pytest.skip("Could not import CookieAssert from cookie_validator.py", allow_module_level=True) - -# --- Test Application Setup --- - -# Define validators once, as they would be in a real app -validate_required_uuid = CookieAssert( - alias="session-id", - format="uuid4", - on_required_error_detail="Session is required.", - on_pattern_error_detail="Invalid session format." -) - -validate_optional_gt10 = CookieAssert( - alias="tracker", - required=False, # Explicitly set to False - default=None, # Provide a default - gt=10, - on_comparison_error_detail="Tracker must be > 10.", - on_numeric_error_detail="Tracker must be a number." -) - -validate_length_5 = CookieAssert( - alias="code", - min_length=5, - max_length=5, - on_length_error_detail="Code must be 5 chars." -) - -def _custom_check(val: str): - """A sample custom validator function""" - if val not in ["admin", "user"]: - raise ValueError("Role is invalid") - return True - -validate_custom_role = CookieAssert( - alias="role", - validator=_custom_check, - on_validator_error_detail="Invalid role." -) - -# Create a minimal FastAPI app for testing -app = FastAPI() - -@app.get("/test-required") -async def get_required(session: str = Depends(validate_required_uuid)): - """Test endpoint for a required, formatted cookie.""" - return {"session": session} - -@app.get("/test-optional") -async def get_optional(tracker: Optional[float] = Depends(validate_optional_gt10)): - """Test endpoint for an optional, numeric cookie.""" - # Note: numeric validators return floats - return {"tracker": tracker} - -@app.get("/test-length") -async def get_length(code: str = Depends(validate_length_5)): - """Test endpoint for a length-constrained cookie.""" - return {"code": code} - -@app.get("/test-custom") -async def get_custom(role: str = Depends(validate_custom_role)): - """Test endpoint for a custom-validated cookie.""" - return {"role": role} - -# --- Pytest Fixtures --- - -@pytest.fixture(scope="module") -def anyio_backend(): - """ - This is the FIX. - Tells pytest-anyio to use the 'asyncio' backend for these tests. - """ - return "asyncio" - - -@pytest.fixture(scope="module") -async def client(anyio_backend): - """ - Pytest fixture to create an AsyncClient for the test app. - Depends on the 'anyio_backend' fixture. - - FIXED: Use ASGITransport instead of app parameter - """ - async with AsyncClient( - transport=ASGITransport(app=app), # <-- FIXED: Wrap app with ASGITransport - base_url="http://test" - ) as ac: - yield ac - -# --- Test Cases --- - -@pytest.mark.anyio # Use 'anyio' marker -async def test_required_cookie_missing(client: AsyncClient): - """Tests that a required cookie raises an error if missing.""" - response = await client.get("/test-required") - assert response.status_code == status.HTTP_400_BAD_REQUEST - assert response.json() == {"detail": "Session is required."} - -@pytest.mark.anyio -async def test_required_cookie_invalid_format(client: AsyncClient): - """Tests that a required cookie fails on invalid format.""" - cookies = {"session-id": "not-a-valid-uuid"} - response = await client.get("/test-required", cookies=cookies) - assert response.status_code == status.HTTP_400_BAD_REQUEST - assert response.json() == {"detail": "Invalid session format."} - -@pytest.mark.anyio -async def test_required_cookie_valid(client: AsyncClient): - """Tests that a required cookie passes with valid format.""" - valid_uuid = str(uuid.uuid4()) - cookies = {"session-id": valid_uuid} - response = await client.get("/test-required", cookies=cookies) - assert response.status_code == status.HTTP_200_OK - assert response.json() == {"session": valid_uuid} - -@pytest.mark.anyio -async def test_optional_cookie_missing(client: AsyncClient): - """Tests that an optional cookie returns the default (None) if missing.""" - response = await client.get("/test-optional") - assert response.status_code == status.HTTP_200_OK - assert response.json() == {"tracker": None} - -@pytest.mark.anyio -async def test_optional_cookie_invalid_comparison(client: AsyncClient): - """Tests that an optional cookie fails numeric comparison.""" - cookies = {"tracker": "5"} # 5 is not > 10 - response = await client.get("/test-optional", cookies=cookies) - assert response.status_code == status.HTTP_400_BAD_REQUEST - assert response.json() == {"detail": "Tracker must be > 10."} - -@pytest.mark.anyio -async def test_optional_cookie_invalid_numeric(client: AsyncClient): - """Tests that a numeric cookie fails non-numeric values.""" - cookies = {"tracker": "not-a-number"} - response = await client.get("/test-optional", cookies=cookies) - assert response.status_code == status.HTTP_400_BAD_REQUEST - assert response.json() == {"detail": "Tracker must be a number."} - -@pytest.mark.anyio -async def test_optional_cookie_valid(client: AsyncClient): - """Tests that an optional cookie passes with a valid value.""" - cookies = {"tracker": "100"} - response = await client.get("/test-optional", cookies=cookies) - assert response.status_code == status.HTTP_200_OK - assert response.json() == {"tracker": 100.0} # Note: value is cast to float - -@pytest.mark.anyio -async def test_length_cookie_too_short(client: AsyncClient): - """Tests min_length validation.""" - cookies = {"code": "1234"} # Length 4, min is 5 - response = await client.get("/test-length", cookies=cookies) - assert response.status_code == status.HTTP_400_BAD_REQUEST - assert response.json() == {"detail": "Code must be 5 chars."} - -@pytest.mark.anyio -async def test_length_cookie_too_long(client: AsyncClient): - """Tests max_length validation.""" - cookies = {"code": "123456"} # Length 6, max is 5 - response = await client.get("/test-length", cookies=cookies) - assert response.status_code == status.HTTP_400_BAD_REQUEST - assert response.json() == {"detail": "Code must be 5 chars."} - -@pytest.mark.anyio -async def test_length_cookie_valid(client: AsyncClient): - """Tests valid length validation.""" - cookies = {"code": "12345"} - response = await client.get("/test-length", cookies=cookies) - assert response.status_code == status.HTTP_200_OK - assert response.json() == {"code": "12345"} - -@pytest.mark.anyio -async def test_custom_validator_fail(client: AsyncClient): - """Tests custom validator function failure.""" - cookies = {"role": "guest"} # "guest" is not in ["admin", "user"] - response = await client.get("/test-custom", cookies=cookies) - assert response.status_code == status.HTTP_400_BAD_REQUEST - # Note: custom validator exceptions are appended to the detail - assert response.json() == {"detail": "Invalid role.: Role is invalid"} - -@pytest.mark.anyio -async def test_custom_validator_pass(client: AsyncClient): - """Tests custom validator function success.""" - cookies = {"role": "admin"} - response = await client.get("/test-custom", cookies=cookies) - assert response.status_code == status.HTTP_200_OK - assert response.json() == {"role": "admin"} diff --git a/tests/test_header_validator.py b/tests/test_header_validator.py index b8b7f21..b3486c3 100644 --- a/tests/test_header_validator.py +++ b/tests/test_header_validator.py @@ -5,7 +5,7 @@ import pytest from fastapi import HTTPException from fastapi_assets.core.base_validator import ValidationError -from fastapi_assets.validators.header_validator import HeaderValidator +from fastapi_assets.request_validators.header_validator import HeaderValidator # --- Fixtures --- From 8364395b547a3c1c262420627648f8cb2b0a3ae7 Mon Sep 17 00:00:00 2001 From: Mohammed-Saajid Date: Mon, 3 Nov 2025 14:40:45 +0530 Subject: [PATCH 21/30] Fixed Bugs, Improved Code Quality, Added Tests --- .gitignore | 3 + .../request_validators/header_validator.py | 89 ++-- .../request_validators/path_validator.py | 16 +- tests/test_header_validator.py | 35 +- tests/test_image_validator.py | 4 +- tests/test_path_validator.py | 473 +++++++++++++++++- uv.lock | 41 ++ 7 files changed, 599 insertions(+), 62 deletions(-) diff --git a/.gitignore b/.gitignore index 8db01e3..2ef4e98 100644 --- a/.gitignore +++ b/.gitignore @@ -97,3 +97,6 @@ target/ # Mkdocs /site/ .env + + +dump/ \ No newline at end of file diff --git a/fastapi_assets/request_validators/header_validator.py b/fastapi_assets/request_validators/header_validator.py index df7de14..7c783e6 100644 --- a/fastapi_assets/request_validators/header_validator.py +++ b/fastapi_assets/request_validators/header_validator.py @@ -1,8 +1,12 @@ """HeaderValidator for validating HTTP headers in FastAPI.""" + import re from typing import Any, Callable, Dict, List, Optional, Union, Pattern from fastapi_assets.core.base_validator import BaseValidator, ValidationError from fastapi import Header +from fastapi.param_functions import _Unset + +Undefined = _Unset # Predefined format patterns for common header validation use cases @@ -25,7 +29,7 @@ class HeaderValidator(BaseValidator): .. code-block:: python from fastapi import FastAPI - from fastapi_assets.validators.header_validator import HeaderValidator + from fastapi_assets.request_validators.header_validator import HeaderValidator app = FastAPI() @@ -63,31 +67,60 @@ def secure_endpoint( def __init__( self, - default: Any = ..., + default: Any = Undefined, *, + required: Optional[bool] = True, alias: Optional[str] = None, convert_underscores: bool = True, pattern: Optional[str] = None, format: Optional[str] = None, allowed_values: Optional[List[str]] = None, validator: Optional[Callable[[str], bool]] = None, - required: Optional[bool] = None, - on_error_detail: Optional[Union[str, Callable[[Any], str]]] = None, title: Optional[str] = None, description: Optional[str] = None, - **header_kwargs: Any + **header_kwargs: Any, ) -> None: - # Call super() with default error handling - super().__init__( - status_code=400, - error_detail=on_error_detail or "Header validation failed." + """ + Initializes the HeaderValidator instance. + + Args: + default (Any): The default value if the header is not provided. + required Optional[bool]: Explicitly set if the header is not required. + alias (Optional[str]): The alias of the header. This is the actual + header name (e.g., "X-API-Key"). + convert_underscores (bool): If `True` (default), underscores in + the variable name will be converted to hyphens in the header name. + pattern (Optional[str]): A regex pattern string that the header + value must match. + format (Optional[str]): A predefined format name (e.g., "uuid4", + "email", "bearer_token") that the header value must match. + Cannot be used with `pattern`. + allowed_values (Optional[List[str]]): A list of exact string + values that are allowed for the header. + validator (Optional[Callable[[str], bool]]): A custom callable that + receives the header value and returns `True` if valid, or + `False` (or raises an Exception) if invalid. + title (Optional[str]): A title for the header in OpenAPI docs. + description (Optional[str]): A description for the header in + OpenAPI docs. + **header_kwargs (Any): Additional keyword arguments passed to the + parent `BaseValidator` (for error handling) and the + underlying `fastapi.Header` dependency. + Includes `status_code` (default 400) and `error_detail` + (default "Header Validation Failed") for error responses. + + Raises: + ValueError: If both `pattern` and `format` are specified, or if + an unknown `format` name is provided. + """ + header_kwargs["status_code"] = header_kwargs.get("status_code", 400) + header_kwargs["error_detail"] = header_kwargs.get( + "error_detail", "Header Validation Failed" ) + # Call super() with default error handling + super().__init__(**header_kwargs) - # Determine if header is required - if required is None: - self._required = default is ... - else: - self._required = required + self._required = required # Store validation rules self._allowed_values = allowed_values @@ -123,11 +156,9 @@ def __init__( convert_underscores=convert_underscores, title=title, description=description, - **header_kwargs + **header_kwargs, ) - # Store custom error detail - self._on_error_detail = on_error_detail def __call__(self, header_value: Optional[str] = None) -> Any: """ FastAPI dependency entry point for header validation. @@ -143,8 +174,10 @@ def __call__(self, header_value: Optional[str] = None) -> Any: """ # If value is None, return a dependency that FastAPI will use if header_value is None: + def dependency(value: Optional[str] = self._header_param) -> Optional[str]: return self._validate(value) + return dependency # If value is provided (for testing), validate directly @@ -166,25 +199,17 @@ def _validate(self, value: Optional[str]) -> Optional[str]: try: self._validate_required(value) except ValidationError as e: - self._raise_error( - value= value, - status_code=e.status_code, - detail=str(e.detail) - ) + self._raise_error(value=value, status_code=e.status_code, detail=str(e.detail)) if value is None or value == "": return value or "" try: self._validate_allowed_values(value) self._validate_pattern(value) self._validate_custom(value) - + except ValidationError as e: # Convert ValidationError to HTTPException - self._raise_error( - value= value, - status_code=e.status_code, - detail=str(e.detail) - ) + self._raise_error(value=value, status_code=e.status_code, detail=str(e.detail)) return value @@ -199,7 +224,7 @@ def _validate_required(self, value: Optional[str]) -> None: ValidationError: If the header is required but missing. """ if self._required and (value is None or value == ""): - detail = self._on_error_detail or "Required header is missing." + detail = "Required header is missing." if callable(detail): detail_str = detail(value) else: @@ -242,9 +267,7 @@ def _validate_pattern(self, value: str) -> None: if not self._pattern.match(value): if self._format_name: - detail = ( - f"Header value does not match the required format: '{self._format_name}'" - ) + detail = f"Header value does not match the required format: '{self._format_name}'" else: detail = ( f"Header value '{value}' does not match the required pattern: " @@ -272,4 +295,4 @@ def _validate_custom(self, value: str) -> None: except Exception as e: # If the validator itself raises an exception, catch it detail = f"Custom validation error: {str(e)}" - raise ValidationError(detail=detail, status_code=400) \ No newline at end of file + raise ValidationError(detail=detail, status_code=400) diff --git a/fastapi_assets/request_validators/path_validator.py b/fastapi_assets/request_validators/path_validator.py index 954b239..363ec4e 100644 --- a/fastapi_assets/request_validators/path_validator.py +++ b/fastapi_assets/request_validators/path_validator.py @@ -1,7 +1,7 @@ """Module providing the PathValidator for validating path parameters in FastAPI.""" import re from typing import Any, Callable, List, Optional, Union -from fastapi import Path +from fastapi import Depends, Path from fastapi_assets.core.base_validator import BaseValidator, ValidationError @@ -22,14 +22,14 @@ class PathValidator(BaseValidator): item_id_validator = PathValidator( gt=0, lt=1000, - on_error_detail="Item ID must be between 1 and 999" + error_detail="Item ID must be between 1 and 999" ) username_validator = PathValidator( min_length=5, max_length=15, pattern=r"^[a-zA-Z0-9]+$", - on_error_detail="Username must be 5-15 alphanumeric characters" + error_detail="Username must be 5-15 alphanumeric characters" ) @app.get("/items/{item_id}") @@ -54,7 +54,6 @@ def __init__( ge: Optional[Union[int, float]] = None, le: Optional[Union[int, float]] = None, validator: Optional[Callable[[Any], bool]] = None, - on_error_detail: Optional[Union[str, Callable[[Any], str]]] = None, # Standard Path() parameters title: Optional[str] = None, description: Optional[str] = None, @@ -76,19 +75,18 @@ def __init__( ge: Value must be greater than or equal to this. le: Value must be less than or equal to this. validator: Custom validation function that takes the value and returns bool. - on_error_detail: Custom error message for validation failures. title: Title for API documentation. description: Description for API documentation. alias: Alternative parameter name. deprecated: Whether the parameter is deprecated. **path_kwargs: Additional arguments passed to FastAPI's Path(). """ + path_kwargs["error_detail"] = path_kwargs.get("error_detail", "Path parameter validation failed.") + path_kwargs["status_code"] = path_kwargs.get("status_code", 400) # Call super() with default error handling super().__init__( - status_code=400, - error_detail=on_error_detail or "Path parameter validation failed." + **path_kwargs ) - # Store validation rules self._allowed_values = allowed_values self._pattern = re.compile(pattern) if pattern else None @@ -134,7 +132,7 @@ def __call__(self, value: Any = None) -> Any: # Return a dependency that FastAPI will use async def dependency(param_value: Any = self._path_param) -> Any: return self._validate(param_value) - return dependency + return Depends(dependency) # If value is provided (for testing), validate directly return self._validate(value) diff --git a/tests/test_header_validator.py b/tests/test_header_validator.py index b3486c3..1f0fd29 100644 --- a/tests/test_header_validator.py +++ b/tests/test_header_validator.py @@ -10,6 +10,7 @@ # --- Fixtures --- + @pytest.fixture def base_validator(): """Returns a basic HeaderValidator with no rules.""" @@ -43,14 +44,16 @@ def allowed_values_validator(): @pytest.fixture def custom_validator_obj(): """Returns a HeaderValidator with custom validator function.""" + def is_even_length(val: str) -> bool: return len(val) % 2 == 0 - + return HeaderValidator(validator=is_even_length) # --- Test Classes --- + class TestHeaderValidatorInit: """Tests for the HeaderValidator's __init__ method.""" @@ -115,9 +118,10 @@ def test_init_allowed_values(self): def test_init_custom_validator_function(self): """Tests that custom validator function is stored.""" + def is_positive(val: str) -> bool: return val.startswith("+") - + validator = HeaderValidator(validator=is_positive) assert validator._custom_validator is not None assert validator._custom_validator("+test") is True @@ -126,8 +130,8 @@ def is_positive(val: str) -> bool: def test_init_custom_error_detail(self): """Tests that custom error detail is stored.""" custom_msg = "Invalid header value" - validator = HeaderValidator(on_error_detail=custom_msg) - assert validator._on_error_detail == custom_msg + validator = HeaderValidator(error_detail=custom_msg) + assert validator._error_detail == custom_msg def test_init_alias(self): """Tests that alias for header name is set.""" @@ -149,7 +153,7 @@ def test_required_missing_value(self, required_validator): """Tests required validation fails when value is None.""" with pytest.raises(ValidationError) as e: required_validator._validate_required(None) - + assert e.value.status_code == 400 assert "missing" in e.value.detail.lower() @@ -188,7 +192,7 @@ def test_allowed_values_invalid(self, allowed_values_validator): """Tests invalid allowed value raises error.""" with pytest.raises(ValidationError) as e: allowed_values_validator._validate_allowed_values("v4") - + assert e.value.status_code == 400 assert "not allowed" in e.value.detail.lower() @@ -227,7 +231,7 @@ def test_pattern_invalid_match(self, pattern_validator): """Tests pattern fails on invalid value.""" with pytest.raises(ValidationError) as e: pattern_validator._validate_pattern("short") - + assert e.value.status_code == 400 assert "does not match" in e.value.detail.lower() @@ -245,7 +249,7 @@ def test_pattern_format_uuid4_invalid(self): validator = HeaderValidator(format="uuid4") with pytest.raises(ValidationError) as e: validator._validate_pattern("not-a-uuid") - + assert "format" in e.value.detail.lower() def test_pattern_format_bearer_token_valid(self, format_validator): @@ -304,16 +308,16 @@ def test_custom_validator_invalid(self, custom_validator_obj): or "custom validation error" in e.value.detail.lower() ) - def test_custom_validator_exception(self): """Tests custom validator exception is caught.""" + def buggy_validator(val: str) -> bool: raise ValueError("Unexpected error") - + validator = HeaderValidator(validator=buggy_validator) with pytest.raises(ValidationError) as e: validator._validate_custom("test") - + assert "custom validation error" in e.value.detail.lower() @@ -323,9 +327,7 @@ class TestHeaderValidatorValidate: def test_validate_valid_header(self): """Tests full validation pipeline with valid header.""" validator = HeaderValidator( - required=True, - allowed_values=["api", "web"], - pattern=r"^[a-z]+$" + required=True, allowed_values=["api", "web"], pattern=r"^[a-z]+$" ) try: result = validator._validate("api") @@ -353,9 +355,10 @@ def test_validate_fails_pattern(self): def test_validate_fails_custom(self): """Tests validation fails on custom validator.""" + def no_spaces(val: str) -> bool: return " " not in val - + validator = HeaderValidator(validator=no_spaces) with pytest.raises(HTTPException): validator._validate("has space") @@ -370,4 +373,4 @@ def test_validate_none_optional_header(self): """Tests optional header with None passes.""" validator = HeaderValidator(required=False) result = validator._validate(None) - assert result is None or result == "" \ No newline at end of file + assert result is None or result == "" diff --git a/tests/test_image_validator.py b/tests/test_image_validator.py index 104746b..904c044 100644 --- a/tests/test_image_validator.py +++ b/tests/test_image_validator.py @@ -32,8 +32,8 @@ class MockBaseValidator: """Mock the BaseValidator class.""" def __init__(self, status_code: int = 400, error_detail: str = "Validation failed."): - self.status_code = status_code - self.error_detail = error_detail + self._status_code = status_code + self._error_detail = error_detail def _raise_error(self, status_code: int, detail: str) -> None: """Mock the error raising to throw HTTPException, as a FastAPI dependency would.""" diff --git a/tests/test_path_validator.py b/tests/test_path_validator.py index e4a31a5..986e4e1 100644 --- a/tests/test_path_validator.py +++ b/tests/test_path_validator.py @@ -78,9 +78,11 @@ def test_init_length_bounds(self): def test_init_custom_error_detail(self): """Tests that custom error messages are stored.""" custom_error = "Invalid path parameter" - validator = PathValidator(on_error_detail=custom_error) + validator = PathValidator(error_detail=custom_error) + print(validator._error_detail) + # _error_detail attribute holds error message - assert validator.error_detail == custom_error or custom_error in str(validator.__dict__) + assert validator._error_detail == custom_error or custom_error in str(validator.__dict__) def test_init_custom_validator_function(self): """Tests that custom validator function is stored.""" @@ -259,3 +261,470 @@ def test_fail_in_combined(self): v = PathValidator(allowed_values=["ok"], pattern=r"^ok$", min_length=2, max_length=2) with pytest.raises(HTTPException): v._validate("no") + + +# Edge case tests for bounds +class TestPathValidatorNumericEdgeCases: + """Test edge cases and boundary conditions for numeric validation.""" + + def test_gt_with_equal_value(self): + """Value equal to gt boundary should fail.""" + validator = PathValidator(gt=10) + with pytest.raises(ValidationError) as exc_info: + validator._validate_numeric_bounds(10) + assert "greater than 10" in str(exc_info.value.detail) + + def test_lt_with_equal_value(self): + """Value equal to lt boundary should fail.""" + validator = PathValidator(lt=10) + with pytest.raises(ValidationError) as exc_info: + validator._validate_numeric_bounds(10) + assert "less than 10" in str(exc_info.value.detail) + + def test_ge_with_equal_value(self): + """Value equal to ge boundary should pass.""" + validator = PathValidator(ge=10) + try: + validator._validate_numeric_bounds(10) + except ValidationError: + pytest.fail("GE with equal value should pass") + + def test_le_with_equal_value(self): + """Value equal to le boundary should pass.""" + validator = PathValidator(le=10) + try: + validator._validate_numeric_bounds(10) + except ValidationError: + pytest.fail("LE with equal value should pass") + + def test_negative_numeric_bounds(self): + """Test numeric bounds with negative values.""" + validator = PathValidator(gt=-100, lt=-10) + try: + validator._validate_numeric_bounds(-50) + except ValidationError: + pytest.fail("Valid negative value failed") + with pytest.raises(ValidationError): + validator._validate_numeric_bounds(-100) + + def test_float_numeric_bounds(self): + """Test numeric bounds with float values.""" + validator = PathValidator(gt=0.0, lt=1.0) + try: + validator._validate_numeric_bounds(0.5) + except ValidationError: + pytest.fail("Valid float value failed") + with pytest.raises(ValidationError): + validator._validate_numeric_bounds(1.0) + + def test_zero_as_boundary(self): + """Test with zero as boundary value.""" + validator = PathValidator(ge=0, le=0) + try: + validator._validate_numeric_bounds(0) + except ValidationError: + pytest.fail("Zero should be valid with ge=0, le=0") + with pytest.raises(ValidationError): + validator._validate_numeric_bounds(1) + + +# Edge case tests for string length +class TestPathValidatorStringEdgeCases: + """Test edge cases and boundary conditions for string validation.""" + + def test_empty_string_with_min_length(self): + """Empty string should fail if min_length is set.""" + validator = PathValidator(min_length=1) + with pytest.raises(ValidationError) as exc_info: + validator._validate_length("") + assert "too short" in str(exc_info.value.detail) + + def test_min_length_exact(self): + """String exactly at min_length should pass.""" + validator = PathValidator(min_length=5) + try: + validator._validate_length("exact") + except ValidationError: + pytest.fail("Exact min_length should pass") + + def test_max_length_exact(self): + """String exactly at max_length should pass.""" + validator = PathValidator(max_length=5) + try: + validator._validate_length("exact") + except ValidationError: + pytest.fail("Exact max_length should pass") + + def test_unicode_string_length(self): + """Test length validation with unicode characters.""" + validator = PathValidator(min_length=3, max_length=5) + try: + validator._validate_length("😀😁😂") # 3 emoji characters + except ValidationError: + pytest.fail("Valid unicode string failed") + + def test_zero_length_bounds(self): + """Test with min and max length of zero.""" + validator = PathValidator(min_length=0, max_length=0) + try: + validator._validate_length("") + except ValidationError: + pytest.fail("Empty string should be valid with min=0, max=0") + with pytest.raises(ValidationError): + validator._validate_length("x") + + +# Edge case tests for pattern matching +class TestPathValidatorPatternEdgeCases: + """Test edge cases for regex pattern validation.""" + + def test_pattern_with_special_characters(self): + """Pattern with special regex characters.""" + validator = PathValidator(pattern=r"^[\w\-\.]+@[\w\-\.]+\.\w+$") + try: + validator._validate_pattern("user-name.test@sub-domain.co.uk") + except ValidationError: + pytest.fail("Valid email-like pattern failed") + with pytest.raises(ValidationError): + validator._validate_pattern("invalid@domain") + + def test_pattern_case_sensitive(self): + """Regex patterns are case-sensitive by default.""" + validator = PathValidator(pattern=r"^[a-z]+$") + try: + validator._validate_pattern("lowercase") + except ValidationError: + pytest.fail("Lowercase letters should match [a-z]") + with pytest.raises(ValidationError): + validator._validate_pattern("UPPERCASE") + + def test_pattern_with_anchors(self): + """Pattern with start and end anchors.""" + validator = PathValidator(pattern=r"^START.*END$") + try: + validator._validate_pattern("START-middle-END") + except ValidationError: + pytest.fail("String with anchors should match") + with pytest.raises(ValidationError): + validator._validate_pattern("MIDDLE-START-END") + + def test_pattern_match_from_start(self): + """re.match() only matches from the start of string.""" + validator = PathValidator(pattern=r"test") + try: + validator._validate_pattern("test_string") + except ValidationError: + pytest.fail("Pattern should match from start") + # This should fail because re.match only checks beginning + with pytest.raises(ValidationError): + validator._validate_pattern("this_is_a_test_string") + + def test_pattern_with_groups(self): + """Pattern with capture groups.""" + validator = PathValidator(pattern=r"^(\d{4})-(\d{2})-(\d{2})$") + try: + validator._validate_pattern("2025-11-04") + except ValidationError: + pytest.fail("Valid date format should match") + with pytest.raises(ValidationError): + validator._validate_pattern("2025/11/04") + + +# Allowed values edge cases +class TestPathValidatorAllowedValuesEdgeCases: + """Test edge cases for allowed values validation.""" + + def test_allowed_values_with_none(self): + """Test when None is in allowed values.""" + validator = PathValidator(allowed_values=[None, "active", "inactive"]) + try: + validator._validate_allowed_values(None) + except ValidationError: + pytest.fail("None should be allowed if in list") + + def test_allowed_values_case_sensitive(self): + """Allowed values matching is case-sensitive.""" + validator = PathValidator(allowed_values=["Active", "Inactive"]) + try: + validator._validate_allowed_values("Active") + except ValidationError: + pytest.fail("Case-sensitive match should work") + with pytest.raises(ValidationError): + validator._validate_allowed_values("active") + + def test_allowed_values_numeric_types(self): + """Test allowed values with numeric types.""" + validator = PathValidator(allowed_values=[1, 2, 3]) + try: + validator._validate_allowed_values(2) + except ValidationError: + pytest.fail("Numeric allowed value should work") + with pytest.raises(ValidationError): + validator._validate_allowed_values("2") # String "2" != int 2 + + def test_allowed_values_empty_list(self): + """Empty allowed values list should reject everything.""" + validator = PathValidator(allowed_values=[]) + with pytest.raises(ValidationError): + validator._validate_allowed_values("anything") + + def test_allowed_values_with_duplicates(self): + """Allowed values list with duplicates.""" + validator = PathValidator(allowed_values=["status", "status", "active"]) + try: + validator._validate_allowed_values("status") + except ValidationError: + pytest.fail("Duplicates shouldn't affect validation") + + +# Custom validator edge cases +class TestPathValidatorCustomValidatorEdgeCases: + """Test edge cases for custom validator functions.""" + + def test_custom_validator_exception_handling(self): + """Custom validator that raises exception.""" + def bad_validator(x): + raise ValueError("Something went wrong") + + validator = PathValidator(validator=bad_validator) + with pytest.raises(ValidationError) as exc_info: + validator._validate_custom("test") + assert "Custom validation error" in str(exc_info.value.detail) + + def test_custom_validator_returns_false(self): + """Custom validator returns False.""" + def always_fail(x): + return False + + validator = PathValidator(validator=always_fail) + with pytest.raises(ValidationError) as exc_info: + validator._validate_custom("test") + assert "Custom validation failed" in str(exc_info.value.detail) + + def test_custom_validator_returns_true(self): + """Custom validator returns True.""" + def always_pass(x): + return True + + validator = PathValidator(validator=always_pass) + try: + validator._validate_custom("test") + except ValidationError: + pytest.fail("Custom validator returning True should pass") + + def test_custom_validator_with_complex_logic(self): + """Custom validator with complex validation logic.""" + def validate_phone(phone): + import re + return bool(re.match(r"^\+?1?\d{9,15}$", str(phone))) + + validator = PathValidator(validator=validate_phone) + try: + validator._validate_custom("+14155552671") + except ValidationError: + pytest.fail("Valid phone should pass") + with pytest.raises(ValidationError): + validator._validate_custom("123") + + def test_custom_validator_lambda(self): + """Custom validator using lambda function.""" + validator = PathValidator(validator=lambda x: len(str(x)) > 3) + try: + validator._validate_custom("test") + except ValidationError: + pytest.fail("Lambda validator should work") + with pytest.raises(ValidationError): + validator._validate_custom("ab") + + +# Complete validation flow tests +class TestPathValidatorCompleteFlow: + """Test complete validation flows with multiple rules.""" + + def test_all_validations_pass(self): + """All validation rules pass together.""" + validator = PathValidator( + allowed_values=["user_123", "admin_456"], + pattern=r"^[a-z]+_\d+$", + min_length=7, + max_length=10, + validator=lambda x: "_" in x + ) + try: + validator._validate("user_123") + except (ValidationError, HTTPException): + pytest.fail("All validations should pass") + + def test_fail_on_first_validation(self): + """Validation fails on first rule.""" + validator = PathValidator( + allowed_values=["valid"], + pattern=r"^[a-z]+$", + min_length=3 + ) + with pytest.raises(HTTPException): + validator._validate("invalid") + + def test_multiple_combined_rules(self): + """Complex scenario with multiple rules.""" + validator = PathValidator( + min_length=5, + max_length=15, + pattern=r"^[a-zA-Z0-9_-]+$", + allowed_values=["user_name", "admin_test", "guest-user"], + validator=lambda x: not x.startswith("_") + ) + for valid_value in ["user_name", "admin_test", "guest-user"]: + try: + validator._validate(valid_value) + except (ValidationError, HTTPException): + pytest.fail(f"'{valid_value}' should be valid") + + def test_validation_error_messages(self): + """Validation error messages are informative.""" + validator = PathValidator( + allowed_values=["a", "b", "c"], + min_length=2, + max_length=5 + ) + try: + validator._validate("d") + except HTTPException as e: + assert "not allowed" in str(e.detail).lower() or "validation" in str(e.detail).lower() + + +# Non-string and non-numeric type handling +class TestPathValidatorTypeHandling: + """Test handling of various data types.""" + + def test_non_string_skips_string_validations(self): + """Non-string types skip string-specific validations.""" + validator = PathValidator(min_length=3, max_length=10) + try: + validator._validate_length(123) + validator._validate_pattern(123) + except ValidationError: + pytest.fail("Non-strings should skip string validations") + + def test_non_numeric_skips_numeric_validations(self): + """Non-numeric types skip numeric-specific validations.""" + validator = PathValidator(gt=0, lt=100) + try: + validator._validate_numeric_bounds("test") + except ValidationError: + pytest.fail("Non-numeric should skip numeric validations") + + def test_boolean_type_validation(self): + """Test validation with boolean values.""" + validator = PathValidator(allowed_values=[True, False]) + try: + validator._validate_allowed_values(True) + validator._validate_allowed_values(False) + except ValidationError: + pytest.fail("Booleans should validate against allowed values") + + def test_list_type_validation(self): + """Test validation with list/collection types.""" + validator = PathValidator( + allowed_values=[[1, 2], [3, 4], [5, 6]], + validator=lambda x: isinstance(x, list) + ) + try: + validator._validate_allowed_values([1, 2]) + validator._validate_custom([3, 4]) + except ValidationError: + pytest.fail("Lists should validate correctly") + + +# Initialization parameter combinations +class TestPathValidatorInitParameterCombinations: + """Test various parameter combinations during initialization.""" + + def test_init_with_all_parameters(self): + """Initialize with all possible parameters.""" + validator = PathValidator( + default=..., + allowed_values=["a", "b"], + pattern=r"^[a-z]$", + min_length=1, + max_length=1, + gt=0, + lt=10, + ge=1, + le=9, + validator=lambda x: x in ["a", "b"], + title="Test Parameter", + description="A test path parameter", + alias="test_param", + deprecated=False, + error_detail="Test error", + status_code=422 + ) + assert validator._allowed_values == ["a", "b"] + assert validator._pattern is not None + assert validator._min_length == 1 + assert validator._max_length == 1 + + def test_init_only_required(self): + """Initialize with only required parameters.""" + validator = PathValidator() + assert validator._allowed_values is None + assert validator._pattern is None + assert validator._min_length is None + assert validator._max_length is None + + def test_init_with_only_custom_validator(self): + """Initialize with only custom validator.""" + custom = lambda x: x > 0 + validator = PathValidator(validator=custom) + assert validator._custom_validator is custom + assert validator._allowed_values is None + + def test_status_code_default(self): + """Default status code should be 400.""" + validator = PathValidator() + # Status code is set in parent class + + +# Error message verification tests +class TestPathValidatorErrorMessages: + """Test that error messages are clear and informative.""" + + def test_allowed_values_error_message(self): + """Error message includes list of allowed values.""" + validator = PathValidator(allowed_values=["a", "b", "c"]) + try: + validator._validate_allowed_values("d") + except ValidationError as e: + assert "a" in str(e.detail) + assert "b" in str(e.detail) + assert "c" in str(e.detail) + + def test_pattern_error_message_includes_pattern(self): + """Error message includes the regex pattern.""" + pattern = r"^[0-9]{3}$" + validator = PathValidator(pattern=pattern) + try: + validator._validate_pattern("abc") + except ValidationError as e: + assert pattern in str(e.detail) + + def test_length_error_message_info(self): + """Length error includes bounds information.""" + validator = PathValidator(min_length=5, max_length=10) + try: + validator._validate_length("ab") + except ValidationError as e: + assert "5" in str(e.detail) + try: + validator._validate_length("a" * 15) + except ValidationError as e: + assert "10" in str(e.detail) + + def test_numeric_bounds_error_messages(self): + """Numeric bounds errors include boundary values.""" + validator = PathValidator(gt=100) + try: + validator._validate_numeric_bounds(50) + except ValidationError as e: + assert "100" in str(e.detail) diff --git a/uv.lock b/uv.lock index 2e16f3b..bbc179b 100644 --- a/uv.lock +++ b/uv.lock @@ -159,6 +159,8 @@ dependencies = [ [package.optional-dependencies] dev = [ + { name = "anyio" }, + { name = "httpx" }, { name = "mkdocs" }, { name = "mkdocs-material" }, { name = "mkdocstrings", extra = ["python"] }, @@ -180,8 +182,10 @@ pandas = [ [package.metadata] requires-dist = [ + { name = "anyio", marker = "extra == 'dev'", specifier = ">=4.0.0" }, { name = "fastapi", specifier = ">=0.119.1" }, { name = "fastapi-assets", extras = ["pandas", "image"], marker = "extra == 'optional'" }, + { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" }, { name = "mkdocs", marker = "extra == 'dev'", specifier = ">=1.6.1" }, { name = "mkdocs-material", marker = "extra == 'dev'", specifier = ">=9.6.22" }, { name = "mkdocstrings", extras = ["python"], marker = "extra == 'dev'", specifier = ">=0.30.1" }, @@ -218,6 +222,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/b1/9ff6578d789a89812ff21e4e0f80ffae20a65d5dd84e7a17873fe3b365be/griffe-1.14.0-py3-none-any.whl", hash = "sha256:0e9d52832cccf0f7188cfe585ba962d2674b241c01916d780925df34873bceb0", size = 144439 }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, +] + [[package]] name = "idna" version = "3.11" From c8fd848bad4c797acdcb1ba8232c66110f680ff9 Mon Sep 17 00:00:00 2001 From: GUGHAN-3001 Date: Fri, 31 Oct 2025 09:33:08 +0530 Subject: [PATCH 22/30] parent 8364395b547a3c1c262420627648f8cb2b0a3ae7 author GUGHAN-3001 1761883388 +0530 committer Mohammed-Saajid 1762266028 +0530 feat(validators): Add CookieValidator --- fastapi_assets/validators/cookie_validator.py | 383 ++++++++++++++++++ tests/test_cookie_validator.py | 214 ++++++++++ 2 files changed, 597 insertions(+) create mode 100644 fastapi_assets/validators/cookie_validator.py create mode 100644 tests/test_cookie_validator.py diff --git a/fastapi_assets/validators/cookie_validator.py b/fastapi_assets/validators/cookie_validator.py new file mode 100644 index 0000000..df1b6f0 --- /dev/null +++ b/fastapi_assets/validators/cookie_validator.py @@ -0,0 +1,383 @@ +"""FastAPI cookie validation with reusable dependencies.""" + +import inspect +import re +from typing import Any, Callable, Dict, Optional, Union + +from fastapi import Request, status + +from fastapi_assets.core.base_validator import BaseValidator +from fastapi_assets.core.exceptions import ValidationError + + +# Pre-built regex patterns for the `format` parameter +PRE_BUILT_PATTERNS: Dict[str, str] = { + "session_id": r"^[A-Za-z0-9_-]{16,128}$", + "uuid4": r"^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[89abAB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$", + "bearer_token": r"^[Bb]earer [A-Za-z0-9\._~\+\/=-]+$", + "email": r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", + "datetime": r"^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|([+-]\d{2}:\d{2}))?$", +} + + +class CookieAssert(BaseValidator): + """ + A class-based dependency to validate FastAPI Cookies with granular control. + + This class is instantiated as a re-usable dependency that can be + injected into FastAPI endpoints using `Depends()`. It provides fine-grained + validation rules and specific error messages for each rule. + + Example: + ```python + from fastapi import FastAPI, Depends + + app = FastAPI() + + validate_session = CookieAssert( + alias="session-id", + format="uuid4", + on_required_error_detail="Invalid or missing session ID.", + on_pattern_error_detail="Session ID must be a valid UUIDv4." + ) + + @app.get("/items/") + async def read_items(session_id: str = Depends(validate_session)): + return {"session_id": session_id} + ``` + """ + + def __init__( + self, + *, + # --- Core Parameters --- + alias: str, + default: Any = ..., + required: Optional[bool] = None, + # --- Validation Rules --- + gt: Optional[float] = None, + ge: Optional[float] = None, + lt: Optional[float] = None, + le: Optional[float] = None, + min_length: Optional[int] = None, + max_length: Optional[int] = None, + regex: Optional[str] = None, + pattern: Optional[str] = None, + format: Optional[str] = None, + validator: Optional[Callable[[Any], bool]] = None, + # --- Granular Error Messages --- + on_required_error_detail: str = "Cookie is required.", + on_numeric_error_detail: str = "Cookie value must be a number.", + on_comparison_error_detail: str = "Cookie value fails comparison rules.", + on_length_error_detail: str = "Cookie value fails length constraints.", + on_pattern_error_detail: str = "Cookie has an invalid format.", + on_validator_error_detail: str = "Cookie failed custom validation.", + # --- Base Error --- + status_code: int = status.HTTP_400_BAD_REQUEST, + error_detail: str = "Cookie validation failed.", + ) -> None: + """ + Initializes the CookieAssert validator. + + Args: + alias (str): (Required) The exact, case-sensitive name of the + cookie (e.g., "session-id"). + default (Any): The default value to return if the cookie is not + present. If not set, `required` defaults to `True`. + required (Optional[bool]): Explicitly set to `True` or `False`. Overrides + `default` for determining if a cookie is required. + gt (Optional[float]): "Greater than" numeric comparison. + ge (Optional[float]): "Greater than or equal to" numeric comparison. + lt (Optional[float]): "Less than" numeric comparison. + le (Optional[float]): "Less than or equal to" numeric comparison. + min_length (Optional[int]): Minimum string length. + max_length (Optional[int]): Maximum string length. + regex (Optional[str]): Custom regex pattern. + pattern (Optional[str]): Alias for `regex`. + format (Optional[str]): A key from `PRE_BUILT_PATTERNS` (e.g., "uuid4"). + validator (Optional[Callable]): A custom validation function (sync or async). + on_required_error_detail (str): Error for missing required cookie. + on_numeric_error_detail (str): Error for float conversion failure. + on_comparison_error_detail (str): Error for gt/ge/lt/le failure. + on_length_error_detail (str): Error for min/max length failure. + on_pattern_error_detail (str): Error for regex/format failure. + on_validator_error_detail (str): Error for custom validator failure. + status_code (int): The default HTTP status code to raise on failure. + error_detail (str): A generic fallback error message. + + Raises: + ValueError: If `regex`/`pattern` and `format` are used simultaneously. + ValueError: If an unknown `format` key is provided. + """ + super().__init__(status_code=status_code, error_detail=error_detail) + + # --- Store Core Parameters --- + self.alias = alias + self.default = default + + # --- FIXED `is_required` logic --- + if required is not None: + self.is_required = required # Use explicit value if provided + else: + # Infer from default only if 'required' was not set + self.is_required = default is ... + + # --- Store Validation Rules --- + self.gt: Optional[float] = gt + self.ge: Optional[float] = ge + self.lt: Optional[float] = lt + self.le: Optional[float] = le + self.min_length: Optional[int] = min_length + self.max_length: Optional[int] = max_length + self.custom_validator: Optional[Callable[[Any], bool]] = validator + + # --- Store Error Messages --- + self.err_required: str = on_required_error_detail + self.err_numeric: str = on_numeric_error_detail + self.err_compare: str = on_comparison_error_detail + self.err_length: str = on_length_error_detail + self.err_pattern: str = on_pattern_error_detail + self.err_validator: str = on_validator_error_detail + + # --- Handle Regex/Pattern --- + self.final_regex_str: Optional[str] = regex or pattern + if self.final_regex_str and format: + raise ValueError( + "Cannot use 'regex'/'pattern' and 'format' simultaneously." + ) + if format: + if format not in PRE_BUILT_PATTERNS: + raise ValueError( + f"Unknown format: '{format}'. " + f"Available: {list(PRE_BUILT_PATTERNS.keys())}" + ) + self.final_regex_str = PRE_BUILT_PATTERNS[format] + + self.final_regex: Optional[re.Pattern[str]] = ( + re.compile(self.final_regex_str) + if self.final_regex_str + else None + ) + + def _validate_numeric(self, value: str) -> Optional[float]: + """ + Tries to convert value to float. Returns float or None. + + This check is only triggered if gt, ge, lt, or le are set. + + Raises: + ValidationError: If conversion to float fails. + """ + if any(v is not None for v in [self.gt, self.ge, self.lt, self.le]): + try: + return float(value) + except (ValueError, TypeError): + raise ValidationError( + detail=self.err_numeric, + status_code=status.HTTP_400_BAD_REQUEST, + ) + return None + + def _validate_comparison(self, value: float) -> None: + """ + Checks gt, ge, lt, le rules against a numeric value. + + Raises: + ValidationError: If any comparison fails. + """ + if self.gt is not None and not value > self.gt: + raise ValidationError( + detail=self.err_compare, + status_code=status.HTTP_400_BAD_REQUEST, + ) + if self.ge is not None and not value >= self.ge: + raise ValidationError( + detail=self.err_compare, + status_code=status.HTTP_400_BAD_REQUEST, + ) + if self.lt is not None and not value < self.lt: + raise ValidationError( + detail=self.err_compare, + status_code=status.HTTP_400_BAD_REQUEST, + ) + if self.le is not None and not value <= self.le: + raise ValidationError( + detail=self.err_compare, + status_code=status.HTTP_400_BAD_REQUEST, + ) + + def _validate_length(self, value: str) -> None: + """ + Checks min_length and max_length rules. + + Raises: + ValidationError: If length constraints fail. + """ + value_len = len(value) + if self.min_length is not None and value_len < self.min_length: + raise ValidationError( + detail=self.err_length, + status_code=status.HTTP_400_BAD_REQUEST, + ) + if self.max_length is not None and value_len > self.max_length: + raise ValidationError( + detail=self.err_length, + status_code=status.HTTP_400_BAD_REQUEST, + ) + + def _validate_pattern(self, value: str) -> None: + """ + Checks regex/format pattern rule. + + Raises: + ValidationError: If the regex pattern does not match. + """ + if self.final_regex and not self.final_regex.search(value): + raise ValidationError( + detail=self.err_pattern, + status_code=status.HTTP_400_BAD_REQUEST, + ) + + async def _validate_custom(self, value: str) -> None: + """ + Runs the custom validator function (sync or async). + + Raises: + ValidationError: If the function returns False or raises an Exception. + """ + if self.custom_validator: + try: + # Handle both sync and async validators + if inspect.iscoroutinefunction(self.custom_validator): + is_valid = await self.custom_validator(value) + else: + is_valid = self.custom_validator(value) + + if not is_valid: + raise ValidationError( + detail=self.err_validator, + status_code=status.HTTP_400_BAD_REQUEST, + ) + except ValidationError: + # Re-raise our own validation errors + raise + except Exception as e: + # Validator function raising an error is a validation failure + raise ValidationError( + detail=f"{self.err_validator}: {e}", + status_code=status.HTTP_400_BAD_REQUEST, + ) + + def _validate_logic( + self, cookie_value: Optional[str] + ) -> Union[float, str, None]: + """ + Pure validation logic (testable without FastAPI). + + This method runs all validation checks and can be tested + independently of FastAPI. + + Args: + cookie_value: The cookie value to validate. + + Returns: + Union[float, str, None]: The validated value (float if numeric, + str otherwise, or None if not required). + + Raises: + ValidationError: If any validation check fails. + """ + # 1. Check for required + if cookie_value is None: + if self.is_required: + raise ValidationError( + detail=self.err_required, + status_code=status.HTTP_400_BAD_REQUEST, + ) + return self.default if self.default is not ... else None + + # 2. Check numeric and comparison + numeric_value = self._validate_numeric(cookie_value) + if numeric_value is not None: + self._validate_comparison(numeric_value) + + # 3. Check length + self._validate_length(cookie_value) + + # 4. Check pattern + self._validate_pattern(cookie_value) + + # 5. Check custom validator (sync version for pure logic) + if self.custom_validator: + try: + if inspect.iscoroutinefunction(self.custom_validator): + # Can't await in sync context, async validators handled in __call__ + pass + else: + is_valid = self.custom_validator(cookie_value) + if not is_valid: + raise ValidationError( + detail=self.err_validator, + status_code=status.HTTP_400_BAD_REQUEST, + ) + except ValidationError: + raise + except Exception as e: + raise ValidationError( + detail=f"{self.err_validator}: {e}", + status_code=status.HTTP_400_BAD_REQUEST, + ) + + # Explicit return + return numeric_value if numeric_value is not None else cookie_value + + async def __call__(self, request: Request) -> Union[float, str, None]: + """ + FastAPI dependency entry point. + + This method is called by FastAPI's dependency injection system. + It retrieves the cookie from the request and runs all validation logic. + + Args: + request (Request): The incoming FastAPI request object. + + Raises: + HTTPException: If any validation fails, this is raised with + the specific status code and detail message. + + Returns: + Union[float, str, None]: The validated cookie value. This will be a + `float` if numeric comparisons were used, otherwise a `str`. + Returns `None` or the `default` value if not required and not present. + """ + try: + # Validate alias is set + if not self.alias: + raise ValidationError( + detail="Internal Server Error: `CookieAssert` must be " + "initialized with an `alias`.", + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + # Extract cookie value from request + cookie_value: Optional[str] = request.cookies.get(self.alias) + + # Run pure validation logic + result = self._validate_logic(cookie_value) + + # Run async custom validator if present + if ( + self.custom_validator + and inspect.iscoroutinefunction(self.custom_validator) + and cookie_value is not None + ): + await self._validate_custom(cookie_value) + + return result + + except ValidationError as e: + # Convert validation error to HTTP exception + self._raise_error(detail=e.detail, status_code=e.status_code) + # This line is never reached (after _raise_error always raises), + # but mypy needs to see it for type completeness + return None # pragma: no cover \ No newline at end of file diff --git a/tests/test_cookie_validator.py b/tests/test_cookie_validator.py new file mode 100644 index 0000000..e0a7c56 --- /dev/null +++ b/tests/test_cookie_validator.py @@ -0,0 +1,214 @@ +""" +Unit Tests for the CookieAssert Validator +========================================= + +This file contains unit tests for the `CookieAssert` class. +It uses `pytest` and `httpx` to create a test FastAPI application +and send requests to it to validate all behaviors. + +This version is modified to use 'pytest-anyio'. + +To run these tests: +1. Make sure `cookie_validator.py` (the main code) is in the same directory. +2. pip install pytest httpx fastapi "uvicorn[standard]" pytest-anyio +3. Run `pytest -v` in your terminal. +""" + +import pytest +import uuid +from typing import Optional +from fastapi import FastAPI, Depends, status +from httpx import AsyncClient, ASGITransport # <-- FIXED: Added ASGITransport + +# Import the class to be tested +# (Assumes cookie_validator.py is in the same directory) +try: + from fastapi_assets.validators.cookie_validator import CookieAssert, ValidationError, BaseValidator +except ImportError: + # This skip allows the test runner to at least start + pytest.skip("Could not import CookieAssert from cookie_validator.py", allow_module_level=True) + +# --- Test Application Setup --- + +# Define validators once, as they would be in a real app +validate_required_uuid = CookieAssert( + alias="session-id", + format="uuid4", + on_required_error_detail="Session is required.", + on_pattern_error_detail="Invalid session format." +) + +validate_optional_gt10 = CookieAssert( + alias="tracker", + required=False, # Explicitly set to False + default=None, # Provide a default + gt=10, + on_comparison_error_detail="Tracker must be > 10.", + on_numeric_error_detail="Tracker must be a number." +) + +validate_length_5 = CookieAssert( + alias="code", + min_length=5, + max_length=5, + on_length_error_detail="Code must be 5 chars." +) + +def _custom_check(val: str): + """A sample custom validator function""" + if val not in ["admin", "user"]: + raise ValueError("Role is invalid") + return True + +validate_custom_role = CookieAssert( + alias="role", + validator=_custom_check, + on_validator_error_detail="Invalid role." +) + +# Create a minimal FastAPI app for testing +app = FastAPI() + +@app.get("/test-required") +async def get_required(session: str = Depends(validate_required_uuid)): + """Test endpoint for a required, formatted cookie.""" + return {"session": session} + +@app.get("/test-optional") +async def get_optional(tracker: Optional[float] = Depends(validate_optional_gt10)): + """Test endpoint for an optional, numeric cookie.""" + # Note: numeric validators return floats + return {"tracker": tracker} + +@app.get("/test-length") +async def get_length(code: str = Depends(validate_length_5)): + """Test endpoint for a length-constrained cookie.""" + return {"code": code} + +@app.get("/test-custom") +async def get_custom(role: str = Depends(validate_custom_role)): + """Test endpoint for a custom-validated cookie.""" + return {"role": role} + +# --- Pytest Fixtures --- + +@pytest.fixture(scope="module") +def anyio_backend(): + """ + This is the FIX. + Tells pytest-anyio to use the 'asyncio' backend for these tests. + """ + return "asyncio" + + +@pytest.fixture(scope="module") +async def client(anyio_backend): + """ + Pytest fixture to create an AsyncClient for the test app. + Depends on the 'anyio_backend' fixture. + + FIXED: Use ASGITransport instead of app parameter + """ + async with AsyncClient( + transport=ASGITransport(app=app), # <-- FIXED: Wrap app with ASGITransport + base_url="http://test" + ) as ac: + yield ac + +# --- Test Cases --- + +@pytest.mark.anyio # Use 'anyio' marker +async def test_required_cookie_missing(client: AsyncClient): + """Tests that a required cookie raises an error if missing.""" + response = await client.get("/test-required") + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"detail": "Session is required."} + +@pytest.mark.anyio +async def test_required_cookie_invalid_format(client: AsyncClient): + """Tests that a required cookie fails on invalid format.""" + cookies = {"session-id": "not-a-valid-uuid"} + response = await client.get("/test-required", cookies=cookies) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"detail": "Invalid session format."} + +@pytest.mark.anyio +async def test_required_cookie_valid(client: AsyncClient): + """Tests that a required cookie passes with valid format.""" + valid_uuid = str(uuid.uuid4()) + cookies = {"session-id": valid_uuid} + response = await client.get("/test-required", cookies=cookies) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"session": valid_uuid} + +@pytest.mark.anyio +async def test_optional_cookie_missing(client: AsyncClient): + """Tests that an optional cookie returns the default (None) if missing.""" + response = await client.get("/test-optional") + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"tracker": None} + +@pytest.mark.anyio +async def test_optional_cookie_invalid_comparison(client: AsyncClient): + """Tests that an optional cookie fails numeric comparison.""" + cookies = {"tracker": "5"} # 5 is not > 10 + response = await client.get("/test-optional", cookies=cookies) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"detail": "Tracker must be > 10."} + +@pytest.mark.anyio +async def test_optional_cookie_invalid_numeric(client: AsyncClient): + """Tests that a numeric cookie fails non-numeric values.""" + cookies = {"tracker": "not-a-number"} + response = await client.get("/test-optional", cookies=cookies) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"detail": "Tracker must be a number."} + +@pytest.mark.anyio +async def test_optional_cookie_valid(client: AsyncClient): + """Tests that an optional cookie passes with a valid value.""" + cookies = {"tracker": "100"} + response = await client.get("/test-optional", cookies=cookies) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"tracker": 100.0} # Note: value is cast to float + +@pytest.mark.anyio +async def test_length_cookie_too_short(client: AsyncClient): + """Tests min_length validation.""" + cookies = {"code": "1234"} # Length 4, min is 5 + response = await client.get("/test-length", cookies=cookies) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"detail": "Code must be 5 chars."} + +@pytest.mark.anyio +async def test_length_cookie_too_long(client: AsyncClient): + """Tests max_length validation.""" + cookies = {"code": "123456"} # Length 6, max is 5 + response = await client.get("/test-length", cookies=cookies) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"detail": "Code must be 5 chars."} + +@pytest.mark.anyio +async def test_length_cookie_valid(client: AsyncClient): + """Tests valid length validation.""" + cookies = {"code": "12345"} + response = await client.get("/test-length", cookies=cookies) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"code": "12345"} + +@pytest.mark.anyio +async def test_custom_validator_fail(client: AsyncClient): + """Tests custom validator function failure.""" + cookies = {"role": "guest"} # "guest" is not in ["admin", "user"] + response = await client.get("/test-custom", cookies=cookies) + assert response.status_code == status.HTTP_400_BAD_REQUEST + # Note: custom validator exceptions are appended to the detail + assert response.json() == {"detail": "Invalid role.: Role is invalid"} + +@pytest.mark.anyio +async def test_custom_validator_pass(client: AsyncClient): + """Tests custom validator function success.""" + cookies = {"role": "admin"} + response = await client.get("/test-custom", cookies=cookies) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"role": "admin"} From 910012adf04fe7b909570f2017ef49f7d45543f4 Mon Sep 17 00:00:00 2001 From: Mohammed-Saajid Date: Tue, 4 Nov 2025 19:39:42 +0530 Subject: [PATCH 23/30] Improved Code Quality and fixed bugs --- .../cookie_validator.py | 116 +++---- tests/test_cookie_validator.py | 293 +++++++++++++++--- 2 files changed, 306 insertions(+), 103 deletions(-) rename fastapi_assets/{validators => request_validators}/cookie_validator.py (80%) diff --git a/fastapi_assets/validators/cookie_validator.py b/fastapi_assets/request_validators/cookie_validator.py similarity index 80% rename from fastapi_assets/validators/cookie_validator.py rename to fastapi_assets/request_validators/cookie_validator.py index df1b6f0..3b394e2 100644 --- a/fastapi_assets/validators/cookie_validator.py +++ b/fastapi_assets/request_validators/cookie_validator.py @@ -2,7 +2,7 @@ import inspect import re -from typing import Any, Callable, Dict, Optional, Union +from typing import Any, Callable, Dict, List, Optional, Union from fastapi import Request, status @@ -34,27 +34,43 @@ class CookieAssert(BaseValidator): app = FastAPI() + def is_whitelisted(user_id: str) -> bool: + # Logic to check if user_id is in a whitelist + return user_id in {"user_1", "user_2"} + validate_session = CookieAssert( - alias="session-id", + "session-id", # This is the required 'alias' format="uuid4", on_required_error_detail="Invalid or missing session ID.", on_pattern_error_detail="Session ID must be a valid UUIDv4." ) + validate_user = CookieAssert( + "user-id", + min_length=6, + validators=[is_whitelisted], + on_length_error_detail="User ID must be at least 6 characters.", + on_validator_error_detail="User is not whitelisted." + ) + @app.get("/items/") async def read_items(session_id: str = Depends(validate_session)): return {"session_id": session_id} + + @app.get("/users/me") + async def read_user(user_id: str = Depends(validate_user)): + return {"user_id": user_id} ``` """ def __init__( self, - *, - # --- Core Parameters --- alias: str, + *, + # Core Parameters default: Any = ..., required: Optional[bool] = None, - # --- Validation Rules --- + # Validation Rules gt: Optional[float] = None, ge: Optional[float] = None, lt: Optional[float] = None, @@ -64,15 +80,15 @@ def __init__( regex: Optional[str] = None, pattern: Optional[str] = None, format: Optional[str] = None, - validator: Optional[Callable[[Any], bool]] = None, - # --- Granular Error Messages --- + validators: Optional[List[Callable[[Any], bool]]] = None, + # Granular Error Messages on_required_error_detail: str = "Cookie is required.", on_numeric_error_detail: str = "Cookie value must be a number.", on_comparison_error_detail: str = "Cookie value fails comparison rules.", on_length_error_detail: str = "Cookie value fails length constraints.", on_pattern_error_detail: str = "Cookie has an invalid format.", on_validator_error_detail: str = "Cookie failed custom validation.", - # --- Base Error --- + # Base Error status_code: int = status.HTTP_400_BAD_REQUEST, error_detail: str = "Cookie validation failed.", ) -> None: @@ -95,7 +111,8 @@ def __init__( regex (Optional[str]): Custom regex pattern. pattern (Optional[str]): Alias for `regex`. format (Optional[str]): A key from `PRE_BUILT_PATTERNS` (e.g., "uuid4"). - validator (Optional[Callable]): A custom validation function (sync or async). + validators (Optional[List[Callable]]): A list of custom validation + functions (sync or async). on_required_error_detail (str): Error for missing required cookie. on_numeric_error_detail (str): Error for float conversion failure. on_comparison_error_detail (str): Error for gt/ge/lt/le failure. @@ -111,27 +128,26 @@ def __init__( """ super().__init__(status_code=status_code, error_detail=error_detail) - # --- Store Core Parameters --- + # Store Core Parameters self.alias = alias self.default = default - # --- FIXED `is_required` logic --- if required is not None: self.is_required = required # Use explicit value if provided else: # Infer from default only if 'required' was not set self.is_required = default is ... - # --- Store Validation Rules --- + # Store Validation Rules self.gt: Optional[float] = gt self.ge: Optional[float] = ge self.lt: Optional[float] = lt self.le: Optional[float] = le self.min_length: Optional[int] = min_length self.max_length: Optional[int] = max_length - self.custom_validator: Optional[Callable[[Any], bool]] = validator + self.custom_validators = validators - # --- Store Error Messages --- + # Store Error Messages self.err_required: str = on_required_error_detail self.err_numeric: str = on_numeric_error_detail self.err_compare: str = on_comparison_error_detail @@ -139,7 +155,7 @@ def __init__( self.err_pattern: str = on_pattern_error_detail self.err_validator: str = on_validator_error_detail - # --- Handle Regex/Pattern --- + # Handle Regex/Pattern self.final_regex_str: Optional[str] = regex or pattern if self.final_regex_str and format: raise ValueError( @@ -240,18 +256,22 @@ def _validate_pattern(self, value: str) -> None: async def _validate_custom(self, value: str) -> None: """ - Runs the custom validator function (sync or async). + Runs all custom validator functions (sync or async). Raises: - ValidationError: If the function returns False or raises an Exception. + ValidationError: If any function returns False or raises an Exception. """ - if self.custom_validator: + if not self.custom_validators: + return + + for validator_func in self.custom_validators: try: + is_valid = None # Handle both sync and async validators - if inspect.iscoroutinefunction(self.custom_validator): - is_valid = await self.custom_validator(value) + if inspect.iscoroutinefunction(validator_func): + is_valid = await validator_func(value) else: - is_valid = self.custom_validator(value) + is_valid = validator_func(value) if not is_valid: raise ValidationError( @@ -268,14 +288,13 @@ async def _validate_custom(self, value: str) -> None: status_code=status.HTTP_400_BAD_REQUEST, ) - def _validate_logic( + async def _validate_logic( self, cookie_value: Optional[str] ) -> Union[float, str, None]: """ Pure validation logic (testable without FastAPI). - This method runs all validation checks and can be tested - independently of FastAPI. + This async method runs all validation checks in order. Args: cookie_value: The cookie value to validate. @@ -307,28 +326,11 @@ def _validate_logic( # 4. Check pattern self._validate_pattern(cookie_value) - # 5. Check custom validator (sync version for pure logic) - if self.custom_validator: - try: - if inspect.iscoroutinefunction(self.custom_validator): - # Can't await in sync context, async validators handled in __call__ - pass - else: - is_valid = self.custom_validator(cookie_value) - if not is_valid: - raise ValidationError( - detail=self.err_validator, - status_code=status.HTTP_400_BAD_REQUEST, - ) - except ValidationError: - raise - except Exception as e: - raise ValidationError( - detail=f"{self.err_validator}: {e}", - status_code=status.HTTP_400_BAD_REQUEST, - ) + # 5. Check custom validators (both sync and async) + await self._validate_custom(cookie_value) - # Explicit return + # Return the float value if numeric checks were run, + # otherwise return the original string value. return numeric_value if numeric_value is not None else cookie_value async def __call__(self, request: Request) -> Union[float, str, None]: @@ -351,33 +353,13 @@ async def __call__(self, request: Request) -> Union[float, str, None]: Returns `None` or the `default` value if not required and not present. """ try: - # Validate alias is set - if not self.alias: - raise ValidationError( - detail="Internal Server Error: `CookieAssert` must be " - "initialized with an `alias`.", - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) - # Extract cookie value from request cookie_value: Optional[str] = request.cookies.get(self.alias) - # Run pure validation logic - result = self._validate_logic(cookie_value) - - # Run async custom validator if present - if ( - self.custom_validator - and inspect.iscoroutinefunction(self.custom_validator) - and cookie_value is not None - ): - await self._validate_custom(cookie_value) - - return result + # Run all validation logic + return await self._validate_logic(cookie_value) except ValidationError as e: # Convert validation error to HTTP exception self._raise_error(detail=e.detail, status_code=e.status_code) - # This line is never reached (after _raise_error always raises), - # but mypy needs to see it for type completeness return None # pragma: no cover \ No newline at end of file diff --git a/tests/test_cookie_validator.py b/tests/test_cookie_validator.py index e0a7c56..664416d 100644 --- a/tests/test_cookie_validator.py +++ b/tests/test_cookie_validator.py @@ -1,32 +1,19 @@ """ Unit Tests for the CookieAssert Validator -========================================= - -This file contains unit tests for the `CookieAssert` class. -It uses `pytest` and `httpx` to create a test FastAPI application -and send requests to it to validate all behaviors. - -This version is modified to use 'pytest-anyio'. - -To run these tests: -1. Make sure `cookie_validator.py` (the main code) is in the same directory. -2. pip install pytest httpx fastapi "uvicorn[standard]" pytest-anyio -3. Run `pytest -v` in your terminal. """ import pytest import uuid from typing import Optional from fastapi import FastAPI, Depends, status -from httpx import AsyncClient, ASGITransport # <-- FIXED: Added ASGITransport +from httpx import AsyncClient, ASGITransport # Import the class to be tested -# (Assumes cookie_validator.py is in the same directory) try: - from fastapi_assets.validators.cookie_validator import CookieAssert, ValidationError, BaseValidator -except ImportError: - # This skip allows the test runner to at least start - pytest.skip("Could not import CookieAssert from cookie_validator.py", allow_module_level=True) + from fastapi_assets.request_validators.cookie_validator import CookieAssert + from fastapi_assets.core.exceptions import ValidationError +except ImportError as e: + pytest.skip(f"Could not import CookieAssert: {e}", allow_module_level=True) # --- Test Application Setup --- @@ -40,8 +27,8 @@ validate_optional_gt10 = CookieAssert( alias="tracker", - required=False, # Explicitly set to False - default=None, # Provide a default + required=False, + default=None, gt=10, on_comparison_error_detail="Tracker must be > 10.", on_numeric_error_detail="Tracker must be a number." @@ -62,10 +49,43 @@ def _custom_check(val: str): validate_custom_role = CookieAssert( alias="role", - validator=_custom_check, + validators=[_custom_check], on_validator_error_detail="Invalid role." ) +# Additional validators for extended tests +validate_bearer_token = CookieAssert( + alias="auth-token", + format="bearer_token", + on_pattern_error_detail="Invalid bearer token format." +) + +validate_numeric_ge_le = CookieAssert( + alias="score", + ge=0, + le=100, + on_comparison_error_detail="Score must be between 0 and 100.", + on_numeric_error_detail="Score must be a number." +) + +def _async_validator(val: str): + """An async custom validator function""" + # This is actually a sync function that will be called + # The CookieAssert supports both sync and async validators + return val.startswith("valid_") + +validate_async_custom = CookieAssert( + alias="async-token", + validators=[_async_validator], + on_validator_error_detail="Token must start with 'valid_'." +) + +validate_email_format = CookieAssert( + alias="user-email", + format="email", + on_pattern_error_detail="Invalid email format." +) + # Create a minimal FastAPI app for testing app = FastAPI() @@ -77,7 +97,6 @@ async def get_required(session: str = Depends(validate_required_uuid)): @app.get("/test-optional") async def get_optional(tracker: Optional[float] = Depends(validate_optional_gt10)): """Test endpoint for an optional, numeric cookie.""" - # Note: numeric validators return floats return {"tracker": tracker} @app.get("/test-length") @@ -90,12 +109,31 @@ async def get_custom(role: str = Depends(validate_custom_role)): """Test endpoint for a custom-validated cookie.""" return {"role": role} +@app.get("/test-bearer") +async def get_bearer(token: str = Depends(validate_bearer_token)): + """Test endpoint for bearer token format.""" + return {"token": token} + +@app.get("/test-ge-le") +async def get_numeric_range(score: float = Depends(validate_numeric_ge_le)): + """Test endpoint for numeric range validation.""" + return {"score": score} + +@app.get("/test-async-custom") +async def get_async_custom(token: str = Depends(validate_async_custom)): + """Test endpoint for async custom validator.""" + return {"token": token} + +@app.get("/test-email") +async def get_email(email: str = Depends(validate_email_format)): + """Test endpoint for email format.""" + return {"email": email} + # --- Pytest Fixtures --- @pytest.fixture(scope="module") def anyio_backend(): """ - This is the FIX. Tells pytest-anyio to use the 'asyncio' backend for these tests. """ return "asyncio" @@ -106,18 +144,17 @@ async def client(anyio_backend): """ Pytest fixture to create an AsyncClient for the test app. Depends on the 'anyio_backend' fixture. - - FIXED: Use ASGITransport instead of app parameter """ async with AsyncClient( - transport=ASGITransport(app=app), # <-- FIXED: Wrap app with ASGITransport + transport=ASGITransport(app=app), base_url="http://test" ) as ac: yield ac # --- Test Cases --- -@pytest.mark.anyio # Use 'anyio' marker +# REQUIRED COOKIE TESTS +@pytest.mark.anyio async def test_required_cookie_missing(client: AsyncClient): """Tests that a required cookie raises an error if missing.""" response = await client.get("/test-required") @@ -141,6 +178,7 @@ async def test_required_cookie_valid(client: AsyncClient): assert response.status_code == status.HTTP_200_OK assert response.json() == {"session": valid_uuid} +# OPTIONAL COOKIE TESTS @pytest.mark.anyio async def test_optional_cookie_missing(client: AsyncClient): """Tests that an optional cookie returns the default (None) if missing.""" @@ -151,7 +189,7 @@ async def test_optional_cookie_missing(client: AsyncClient): @pytest.mark.anyio async def test_optional_cookie_invalid_comparison(client: AsyncClient): """Tests that an optional cookie fails numeric comparison.""" - cookies = {"tracker": "5"} # 5 is not > 10 + cookies = {"tracker": "5"} # 5 is not > 10 response = await client.get("/test-optional", cookies=cookies) assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.json() == {"detail": "Tracker must be > 10."} @@ -170,12 +208,29 @@ async def test_optional_cookie_valid(client: AsyncClient): cookies = {"tracker": "100"} response = await client.get("/test-optional", cookies=cookies) assert response.status_code == status.HTTP_200_OK - assert response.json() == {"tracker": 100.0} # Note: value is cast to float + assert response.json() == {"tracker": 100.0} + +@pytest.mark.anyio +async def test_optional_cookie_boundary_gt(client: AsyncClient): + """Tests boundary condition for gt comparison (10 is not > 10).""" + cookies = {"tracker": "10"} + response = await client.get("/test-optional", cookies=cookies) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"detail": "Tracker must be > 10."} +@pytest.mark.anyio +async def test_optional_cookie_boundary_gt_valid(client: AsyncClient): + """Tests boundary condition for gt comparison (10.1 is > 10).""" + cookies = {"tracker": "10.1"} + response = await client.get("/test-optional", cookies=cookies) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"tracker": 10.1} + +# LENGTH CONSTRAINT TESTS @pytest.mark.anyio async def test_length_cookie_too_short(client: AsyncClient): """Tests min_length validation.""" - cookies = {"code": "1234"} # Length 4, min is 5 + cookies = {"code": "1234"} # Length 4, min is 5 response = await client.get("/test-length", cookies=cookies) assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.json() == {"detail": "Code must be 5 chars."} @@ -183,7 +238,7 @@ async def test_length_cookie_too_short(client: AsyncClient): @pytest.mark.anyio async def test_length_cookie_too_long(client: AsyncClient): """Tests max_length validation.""" - cookies = {"code": "123456"} # Length 6, max is 5 + cookies = {"code": "123456"} # Length 6, max is 5 response = await client.get("/test-length", cookies=cookies) assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.json() == {"detail": "Code must be 5 chars."} @@ -196,19 +251,185 @@ async def test_length_cookie_valid(client: AsyncClient): assert response.status_code == status.HTTP_200_OK assert response.json() == {"code": "12345"} +@pytest.mark.anyio +async def test_length_cookie_min_boundary(client: AsyncClient): + """Tests minimum boundary condition.""" + cookies = {"code": ""} # Empty string + response = await client.get("/test-length", cookies=cookies) + assert response.status_code == status.HTTP_400_BAD_REQUEST + +# CUSTOM VALIDATOR TESTS @pytest.mark.anyio async def test_custom_validator_fail(client: AsyncClient): """Tests custom validator function failure.""" - cookies = {"role": "guest"} # "guest" is not in ["admin", "user"] + cookies = {"role": "guest"} # "guest" is not in ["admin", "user"] response = await client.get("/test-custom", cookies=cookies) assert response.status_code == status.HTTP_400_BAD_REQUEST - # Note: custom validator exceptions are appended to the detail - assert response.json() == {"detail": "Invalid role.: Role is invalid"} + assert "Invalid role." in response.json()["detail"] @pytest.mark.anyio -async def test_custom_validator_pass(client: AsyncClient): - """Tests custom validator function success.""" +async def test_custom_validator_pass_admin(client: AsyncClient): + """Tests custom validator function success with 'admin'.""" cookies = {"role": "admin"} response = await client.get("/test-custom", cookies=cookies) assert response.status_code == status.HTTP_200_OK assert response.json() == {"role": "admin"} + +@pytest.mark.anyio +async def test_custom_validator_pass_user(client: AsyncClient): + """Tests custom validator function success with 'user'.""" + cookies = {"role": "user"} + response = await client.get("/test-custom", cookies=cookies) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"role": "user"} + +# FORMAT PATTERN TESTS +@pytest.mark.anyio +async def test_bearer_token_valid_format(client: AsyncClient): + """Tests valid bearer token format.""" + cookies = {"auth-token": "Bearer abc123.def456.ghi789"} + response = await client.get("/test-bearer", cookies=cookies) + assert response.status_code == status.HTTP_200_OK + +@pytest.mark.anyio +async def test_bearer_token_lowercase_bearer(client: AsyncClient): + """Tests bearer token with lowercase 'bearer'.""" + cookies = {"auth-token": "bearer abc123.def456.ghi789"} + response = await client.get("/test-bearer", cookies=cookies) + assert response.status_code == status.HTTP_200_OK + +@pytest.mark.anyio +async def test_bearer_token_invalid_no_bearer_prefix(client: AsyncClient): + """Tests bearer token missing 'Bearer' prefix.""" + cookies = {"auth-token": "abc123.def456.ghi789"} + response = await client.get("/test-bearer", cookies=cookies) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"detail": "Invalid bearer token format."} + +@pytest.mark.anyio +async def test_email_valid_format(client: AsyncClient): + """Tests valid email format.""" + cookies = {"user-email": "user@example.com"} + response = await client.get("/test-email", cookies=cookies) + assert response.status_code == status.HTTP_200_OK + +@pytest.mark.anyio +async def test_email_with_plus_sign(client: AsyncClient): + """Tests valid email with plus sign.""" + cookies = {"user-email": "user+tag@example.com"} + response = await client.get("/test-email", cookies=cookies) + assert response.status_code == status.HTTP_200_OK + +@pytest.mark.anyio +async def test_email_invalid_format_no_at(client: AsyncClient): + """Tests invalid email without @ symbol.""" + cookies = {"user-email": "userexample.com"} + response = await client.get("/test-email", cookies=cookies) + assert response.status_code == status.HTTP_400_BAD_REQUEST + +@pytest.mark.anyio +async def test_email_invalid_format_no_domain(client: AsyncClient): + """Tests invalid email without domain.""" + cookies = {"user-email": "user@"} + response = await client.get("/test-email", cookies=cookies) + assert response.status_code == status.HTTP_400_BAD_REQUEST + +# NUMERIC RANGE TESTS +@pytest.mark.anyio +async def test_numeric_range_valid_min(client: AsyncClient): + """Tests numeric value at minimum boundary (ge=0).""" + cookies = {"score": "0"} + response = await client.get("/test-ge-le", cookies=cookies) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"score": 0.0} + +@pytest.mark.anyio +async def test_numeric_range_valid_max(client: AsyncClient): + """Tests numeric value at maximum boundary (le=100).""" + cookies = {"score": "100"} + response = await client.get("/test-ge-le", cookies=cookies) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"score": 100.0} + +@pytest.mark.anyio +async def test_numeric_range_valid_middle(client: AsyncClient): + """Tests numeric value in middle of range.""" + cookies = {"score": "50"} + response = await client.get("/test-ge-le", cookies=cookies) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"score": 50.0} + +@pytest.mark.anyio +async def test_numeric_range_below_min(client: AsyncClient): + """Tests numeric value below minimum (< 0).""" + cookies = {"score": "-1"} + response = await client.get("/test-ge-le", cookies=cookies) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"detail": "Score must be between 0 and 100."} + +@pytest.mark.anyio +async def test_numeric_range_above_max(client: AsyncClient): + """Tests numeric value above maximum (> 100).""" + cookies = {"score": "101"} + response = await client.get("/test-ge-le", cookies=cookies) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"detail": "Score must be between 0 and 100."} + +@pytest.mark.anyio +async def test_numeric_range_float_valid(client: AsyncClient): + """Tests decimal numeric value within range.""" + cookies = {"score": "75.5"} + response = await client.get("/test-ge-le", cookies=cookies) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"score": 75.5} + +@pytest.mark.anyio +async def test_numeric_range_non_numeric(client: AsyncClient): + """Tests non-numeric value.""" + cookies = {"score": "not-a-number"} + response = await client.get("/test-ge-le", cookies=cookies) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"detail": "Score must be a number."} + +# ASYNC VALIDATOR TESTS +@pytest.mark.anyio +async def test_async_validator_valid(client: AsyncClient): + """Tests async validator function success.""" + cookies = {"async-token": "valid_token123"} + response = await client.get("/test-async-custom", cookies=cookies) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"token": "valid_token123"} + +@pytest.mark.anyio +async def test_async_validator_invalid(client: AsyncClient): + """Tests async validator function failure.""" + cookies = {"async-token": "invalid_token123"} + response = await client.get("/test-async-custom", cookies=cookies) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "Token must start with 'valid_'." in response.json()["detail"] + +# EDGE CASE TESTS +@pytest.mark.anyio +async def test_cookie_with_special_characters(client: AsyncClient): + """Tests cookie value containing special characters.""" + cookies = {"code": "a@b#c"} # 5 characters exactly + response = await client.get("/test-length", cookies=cookies) + assert response.status_code == status.HTTP_200_OK + +@pytest.mark.anyio +async def test_cookie_with_spaces(client: AsyncClient): + """Tests cookie value containing spaces.""" + # This should pass length check (5 chars including space) + cookies = {"code": "a b c"} + response = await client.get("/test-length", cookies=cookies) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"code": "a b c"} + +@pytest.mark.anyio +async def test_cookie_with_unicode_characters(client: AsyncClient): + """Tests cookie value containing numeric and special characters.""" + # Note: Unicode characters in cookies require URL encoding, which httpx handles + # For simplicity, we'll test with ASCII-safe alphanumeric and special chars + cookies = {"code": "abc12"} # 5 characters + response = await client.get("/test-length", cookies=cookies) + assert response.status_code == status.HTTP_200_OK From 92421e21601bf17da30275e296f11527abb0860b Mon Sep 17 00:00:00 2001 From: Mohammed Saajid S <141727149+Mohammed-Saajid@users.noreply.github.com> Date: Mon, 10 Nov 2025 14:01:54 +0530 Subject: [PATCH 24/30] Fixed Bugs, Entirely Rewritten Logic, Improved Efficiency (#22) * Fixed Bugs, Entirely Rewritten Logic, Improved Efficiency * Fixed Bug --- fastapi_assets/core/__init__.py | 3 + fastapi_assets/core/base_validator.py | 90 +- .../metadata_validators/__init__.py | 1 - fastapi_assets/request_validators/__init__.py | 4 + .../request_validators/cookie_validator.py | 110 +- .../request_validators/header_validator.py | 330 +++--- .../request_validators/path_validator.py | 305 ++---- fastapi_assets/validators/__init__.py | 4 + fastapi_assets/validators/csv_validator.py | 116 +- fastapi_assets/validators/file_validator.py | 89 +- fastapi_assets/validators/image_validator.py | 77 +- tests/test_base_validator.py | 8 +- tests/test_cookie_validator.py | 96 +- tests/test_csv_validator.py | 19 +- tests/test_file_validator.py | 4 +- tests/test_header_validator.py | 175 ++-- tests/test_image_validator.py | 6 +- tests/test_path_validator.py | 987 +++++------------- 18 files changed, 1056 insertions(+), 1368 deletions(-) delete mode 100644 fastapi_assets/metadata_validators/__init__.py diff --git a/fastapi_assets/core/__init__.py b/fastapi_assets/core/__init__.py index 03d97ae..b2d120c 100644 --- a/fastapi_assets/core/__init__.py +++ b/fastapi_assets/core/__init__.py @@ -1 +1,4 @@ """Module for core functionalities of FastAPI Assets.""" + +from fastapi_assets.core.base_validator import BaseValidator +from fastapi_assets.core.exceptions import ValidationError diff --git a/fastapi_assets/core/base_validator.py b/fastapi_assets/core/base_validator.py index 1824dbb..6b86ad6 100644 --- a/fastapi_assets/core/base_validator.py +++ b/fastapi_assets/core/base_validator.py @@ -1,9 +1,10 @@ """Base classes for FastAPI validation dependencies.""" import abc -from typing import Any, Callable, Optional, Union +from typing import Any, Callable, Optional, Union, List from fastapi import HTTPException from fastapi_assets.core.exceptions import ValidationError +import inspect class BaseValidator(abc.ABC): @@ -20,7 +21,7 @@ class BaseValidator(abc.ABC): from fastapi import Header from fastapi_assets.core.base_validator import BaseValidator, ValidationError class MyValidator(BaseValidator): - def _validate_logic(self, token: str) -> None: + def _validate(self, token: str) -> None: # This method is testable without FastAPI if not token.startswith("sk_"): # Raise the logic-level exception @@ -44,6 +45,7 @@ def __init__( *, status_code: int = 400, error_detail: Union[str, Callable[[Any], str]] = "Validation failed.", + validators: Optional[List[Callable]] = None, ): """ Initializes the base validator. @@ -54,9 +56,11 @@ def __init__( error_detail: The default error message. Can be a static string or a callable that takes the invalid value as its argument and returns a dynamic error string. + validators: Optional list of callables for custom validation logic. """ self._status_code = status_code self._error_detail = error_detail + self._custom_validators = validators or [] def _raise_error( self, @@ -65,17 +69,25 @@ def _raise_error( detail: Optional[Union[str, Callable[[Any], str]]] = None, ) -> None: """ - Helper method to raise a standardized HTTPException. + Raises a standardized HTTPException with resolved error detail. - It automatically resolves callable error details. + This helper method handles both static error strings and dynamic error + callables, automatically resolving them to a final error message before + raising the HTTPException. Args: - value (Optional[Any]): The value that failed validation. This is passed - to the error_detail callable, if it is one. - status_code (Optional[int]): A specific status code for this failure, - overriding the instance's default status_code. - detail (Optional[Union[str, Callable[[Any], str]]]): A specific error detail for this failure, - overriding the instance's default error_detail. + value: The value that failed validation. Passed to the error_detail + callable if it is callable. + status_code: A specific HTTP status code for this failure, overriding + the instance's default status_code. + detail: A specific error detail message (string or callable) for this + failure, overriding the instance's default error_detail. + + Returns: + None + + Raises: + HTTPException: Always raises with the resolved status code and detail. """ final_status_code = status_code if status_code is not None else self._status_code @@ -91,6 +103,59 @@ def _raise_error( raise HTTPException(status_code=final_status_code, detail=final_detail) + @abc.abstractmethod + async def _validate(self, value: Any) -> Any: + """ + Abstract method for pure validation logic. + + Subclasses MUST implement this method to perform the actual + validation. This method should raise `ValidationError` if + validation fails. + + Args: + value: The value to validate. + + Returns: + The validated value, which can be of any type depending on the validator. + """ + raise NotImplementedError( + "Subclasses of BaseValidator must implement the _validate method." + ) + + async def _validate_custom(self, value: Any) -> None: + """ + Executes all configured custom validator functions. + + Iterates through the list of custom validators, supporting both + synchronous and asynchronous validator functions. Catches exceptions + and converts them to ValidationError instances. + + Args: + value: The value to validate using custom validators. + + Returns: + None + + Raises: + ValidationError: If any validator raises an exception or explicitly + raises ValidationError. + """ + if self._custom_validators is None: + return + + for validator_func in self._custom_validators: + try: + if inspect.iscoroutinefunction(validator_func): + await validator_func(value) + else: + validator_func(value) + except ValidationError: + raise # Re-raise explicit validation errors + except Exception as e: + # Catch any other exception from the validator + detail = f"Custom validation failed. Error: {e}" + raise ValidationError(detail=detail, status_code=self._status_code) + @abc.abstractmethod def __call__(self, *args: Any, **kwargs: Any) -> Any: """ @@ -111,7 +176,7 @@ def __call__(self, *args: Any, **kwargs: Any) -> Any: class MyValidator(BaseValidator): - def _validate_logic(self, token: str) -> None: + async def _validate(self, token: str) -> None: # This method is testable without FastAPI if not token.startswith("sk_"): # Raise the logic-level exception @@ -120,7 +185,8 @@ def _validate_logic(self, token: str) -> None: def __call__(self, x_token: str = Header(...)): try: # 1. Run the pure validation logic - self._validate_logic(x_token) + await self._validate(x_token) + await self._validate_custom(x_token) except ValidationError as e: # 2. Catch logic error and raise HTTP error self._raise_error( diff --git a/fastapi_assets/metadata_validators/__init__.py b/fastapi_assets/metadata_validators/__init__.py deleted file mode 100644 index 5581d03..0000000 --- a/fastapi_assets/metadata_validators/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Module for metadata validation in FastAPI Assets.""" diff --git a/fastapi_assets/request_validators/__init__.py b/fastapi_assets/request_validators/__init__.py index 860dc57..d34a237 100644 --- a/fastapi_assets/request_validators/__init__.py +++ b/fastapi_assets/request_validators/__init__.py @@ -1 +1,5 @@ """Module for request validation in FastAPI Assets.""" + +from fastapi_assets.request_validators.header_validator import HeaderValidator +from fastapi_assets.request_validators.cookie_validator import CookieValidator +from fastapi_assets.request_validators.path_validator import PathValidator diff --git a/fastapi_assets/request_validators/cookie_validator.py b/fastapi_assets/request_validators/cookie_validator.py index 3b394e2..355611a 100644 --- a/fastapi_assets/request_validators/cookie_validator.py +++ b/fastapi_assets/request_validators/cookie_validator.py @@ -3,11 +3,8 @@ import inspect import re from typing import Any, Callable, Dict, List, Optional, Union - from fastapi import Request, status - -from fastapi_assets.core.base_validator import BaseValidator -from fastapi_assets.core.exceptions import ValidationError +from fastapi_assets.core import BaseValidator, ValidationError # Pre-built regex patterns for the `format` parameter @@ -20,7 +17,7 @@ } -class CookieAssert(BaseValidator): +class CookieValidator(BaseValidator): """ A class-based dependency to validate FastAPI Cookies with granular control. @@ -31,22 +28,27 @@ class CookieAssert(BaseValidator): Example: ```python from fastapi import FastAPI, Depends + from fastapi_assets.core import ValidationError + from fastapi_assets.request_validators import CookieValidator app = FastAPI() - def is_whitelisted(user_id: str) -> bool: - # Logic to check if user_id is in a whitelist - return user_id in {"user_1", "user_2"} + def is_whitelisted(user_id: str) -> None: + # Logic to check if user_id is in a whitelist. + # Custom validators must raise ValidationError on failure. + if user_id not in {"user_1", "user_2"}: + raise ValidationError("User is not whitelisted.") - validate_session = CookieAssert( - "session-id", # This is the required 'alias' + # Create validators that will extract cookies from the incoming request + validate_session = CookieValidator( + "session-id", # Cookie name to extract from request.cookies format="uuid4", on_required_error_detail="Invalid or missing session ID.", on_pattern_error_detail="Session ID must be a valid UUIDv4." ) - validate_user = CookieAssert( - "user-id", + validate_user = CookieValidator( + "user-id", # Cookie name to extract from request.cookies min_length=6, validators=[is_whitelisted], on_length_error_detail="User ID must be at least 6 characters.", @@ -55,10 +57,14 @@ def is_whitelisted(user_id: str) -> bool: @app.get("/items/") async def read_items(session_id: str = Depends(validate_session)): + # validate_session extracts the "session-id" cookie from the request, + # validates it, and returns the validated value return {"session_id": session_id} @app.get("/users/me") async def read_user(user_id: str = Depends(validate_user)): + # validate_user extracts the "user-id" cookie from the request, + # validates it (including length and custom validators), and returns it return {"user_id": user_id} ``` """ @@ -80,7 +86,7 @@ def __init__( regex: Optional[str] = None, pattern: Optional[str] = None, format: Optional[str] = None, - validators: Optional[List[Callable[[Any], bool]]] = None, + validators: Optional[List[Callable[[Any], Any]]] = None, # Granular Error Messages on_required_error_detail: str = "Cookie is required.", on_numeric_error_detail: str = "Cookie value must be a number.", @@ -89,7 +95,7 @@ def __init__( on_pattern_error_detail: str = "Cookie has an invalid format.", on_validator_error_detail: str = "Cookie failed custom validation.", # Base Error - status_code: int = status.HTTP_400_BAD_REQUEST, + status_code: int = 400, error_detail: str = "Cookie validation failed.", ) -> None: """ @@ -126,7 +132,7 @@ def __init__( ValueError: If `regex`/`pattern` and `format` are used simultaneously. ValueError: If an unknown `format` key is provided. """ - super().__init__(status_code=status_code, error_detail=error_detail) + super().__init__(status_code=status_code, error_detail=error_detail, validators=validators) # Store Core Parameters self.alias = alias @@ -158,21 +164,16 @@ def __init__( # Handle Regex/Pattern self.final_regex_str: Optional[str] = regex or pattern if self.final_regex_str and format: - raise ValueError( - "Cannot use 'regex'/'pattern' and 'format' simultaneously." - ) + raise ValueError("Cannot use 'regex'/'pattern' and 'format' simultaneously.") if format: if format not in PRE_BUILT_PATTERNS: raise ValueError( - f"Unknown format: '{format}'. " - f"Available: {list(PRE_BUILT_PATTERNS.keys())}" + f"Unknown format: '{format}'. Available: {list(PRE_BUILT_PATTERNS.keys())}" ) self.final_regex_str = PRE_BUILT_PATTERNS[format] self.final_regex: Optional[re.Pattern[str]] = ( - re.compile(self.final_regex_str) - if self.final_regex_str - else None + re.compile(self.final_regex_str) if self.final_regex_str else None ) def _validate_numeric(self, value: str) -> Optional[float]: @@ -180,7 +181,11 @@ def _validate_numeric(self, value: str) -> Optional[float]: Tries to convert value to float. Returns float or None. This check is only triggered if gt, ge, lt, or le are set. - + Args: + value (str): The cookie value to convert. + Returns: + Optional[float]: The converted float value, or None if numeric checks + are not applicable. Raises: ValidationError: If conversion to float fails. """ @@ -198,6 +203,10 @@ def _validate_comparison(self, value: float) -> None: """ Checks gt, ge, lt, le rules against a numeric value. + Args: + value (float): The numeric value to compare. + Returns: + None Raises: ValidationError: If any comparison fails. """ @@ -226,6 +235,12 @@ def _validate_length(self, value: str) -> None: """ Checks min_length and max_length rules. + Args: + value (str): The cookie value to check. + + Returns: + None + Raises: ValidationError: If length constraints fail. """ @@ -244,6 +259,10 @@ def _validate_length(self, value: str) -> None: def _validate_pattern(self, value: str) -> None: """ Checks regex/format pattern rule. + Args: + value (str): The cookie value to check. + Returns: + None Raises: ValidationError: If the regex pattern does not match. @@ -254,43 +273,7 @@ def _validate_pattern(self, value: str) -> None: status_code=status.HTTP_400_BAD_REQUEST, ) - async def _validate_custom(self, value: str) -> None: - """ - Runs all custom validator functions (sync or async). - - Raises: - ValidationError: If any function returns False or raises an Exception. - """ - if not self.custom_validators: - return - - for validator_func in self.custom_validators: - try: - is_valid = None - # Handle both sync and async validators - if inspect.iscoroutinefunction(validator_func): - is_valid = await validator_func(value) - else: - is_valid = validator_func(value) - - if not is_valid: - raise ValidationError( - detail=self.err_validator, - status_code=status.HTTP_400_BAD_REQUEST, - ) - except ValidationError: - # Re-raise our own validation errors - raise - except Exception as e: - # Validator function raising an error is a validation failure - raise ValidationError( - detail=f"{self.err_validator}: {e}", - status_code=status.HTTP_400_BAD_REQUEST, - ) - - async def _validate_logic( - self, cookie_value: Optional[str] - ) -> Union[float, str, None]: + async def _validate(self, cookie_value: Optional[str]) -> Union[float, str, None]: """ Pure validation logic (testable without FastAPI). @@ -355,11 +338,10 @@ async def __call__(self, request: Request) -> Union[float, str, None]: try: # Extract cookie value from request cookie_value: Optional[str] = request.cookies.get(self.alias) - # Run all validation logic - return await self._validate_logic(cookie_value) + return await self._validate(cookie_value) except ValidationError as e: # Convert validation error to HTTP exception self._raise_error(detail=e.detail, status_code=e.status_code) - return None # pragma: no cover \ No newline at end of file + return None # pragma: no cover diff --git a/fastapi_assets/request_validators/header_validator.py b/fastapi_assets/request_validators/header_validator.py index 7c783e6..4ae9dab 100644 --- a/fastapi_assets/request_validators/header_validator.py +++ b/fastapi_assets/request_validators/header_validator.py @@ -1,11 +1,13 @@ """HeaderValidator for validating HTTP headers in FastAPI.""" +from inspect import Signature, Parameter import re -from typing import Any, Callable, Dict, List, Optional, Union, Pattern -from fastapi_assets.core.base_validator import BaseValidator, ValidationError +from typing import Any, Callable, Dict, List, Optional, Pattern from fastapi import Header from fastapi.param_functions import _Unset +from fastapi_assets.core import BaseValidator, ValidationError + Undefined = _Unset @@ -22,62 +24,83 @@ class HeaderValidator(BaseValidator): r""" - A general-purpose dependency for validating HTTP request headers in FastAPI. + A dependency for validating HTTP headers with extended rules. - It extends FastAPI's built-in Header with additional validation capabilities - including pattern matching, format validation, allowed values, and custom validators. + Extends FastAPI's `Header` with pattern matching, format validation, + allowed values, and custom validators, providing granular error control. + Example: .. code-block:: python - from fastapi import FastAPI - from fastapi_assets.request_validators.header_validator import HeaderValidator + from fastapi import FastAPI, Depends + from fastapi_assets.request_validators import HeaderValidator app = FastAPI() - # Validate API key header with pattern + def is_valid_api_version(version: str) -> bool: + # Custom validators must raise ValidationError on failure + if version not in ["v1", "v2", "v3"]: + raise ValidationError(detail="Unsupported API version.") + + # Validate required API key header with a specific pattern. + # HeaderValidator extracts the header from the incoming request automatically. api_key_validator = HeaderValidator( alias="X-API-Key", pattern=r"^[a-zA-Z0-9]{32}$", - required=True, - on_error_detail="Invalid API key format" + on_required_error_detail="X-API-Key header is missing.", + on_pattern_error_detail="Invalid API key format." ) - # Validate authorization header with bearer token format + # Validate required authorization header with bearer token format. + # The header is extracted from request.headers by the Header() dependency. auth_validator = HeaderValidator( alias="Authorization", format="bearer_token", - required=True + on_required_error_detail="Authorization header is required.", + on_pattern_error_detail="Invalid Bearer token format." ) - # Validate custom header with allowed values + # Validate optional custom header with custom validator and a default. + # If not provided, the default value "v1" will be used. version_validator = HeaderValidator( alias="X-API-Version", - allowed_values=["v1", "v2", "v3"], - required=False, - default="v1" + default="v1", + validators=[is_valid_api_version], + on_custom_validator_error_detail="Invalid API version." ) @app.get("/secure") - def secure_endpoint( - api_key: str = api_key_validator, - auth: str = auth_validator, - version: str = version_validator + async def secure_endpoint( + api_key: str = Depends(api_key_validator), + auth: str = Depends(auth_validator), + version: str = Depends(version_validator) ): + # Each dependency automatically extracts and validates the corresponding header, + # returning the validated value to the endpoint return {"message": "Access granted", "version": version} + ``` """ def __init__( self, default: Any = Undefined, *, - required: Optional[bool] = True, alias: Optional[str] = None, convert_underscores: bool = True, pattern: Optional[str] = None, format: Optional[str] = None, allowed_values: Optional[List[str]] = None, - validator: Optional[Callable[[str], bool]] = None, + validators: Optional[List[Callable[[Any], Any]]] = None, + # Standard Header parameters title: Optional[str] = None, description: Optional[str] = None, + # Granular Error Messages + on_required_error_detail: str = "Required header is missing.", + on_pattern_error_detail: str = "Header has an invalid format.", + on_allowed_values_error_detail: str = "Header value is not allowed.", + on_custom_validator_error_detail: str = "Header failed custom validation.", + # Base Error + status_code: int = 400, + error_detail: str = "Header Validation Failed", **header_kwargs: Any, ) -> None: """ @@ -85,73 +108,71 @@ def __init__( Args: default (Any): The default value if the header is not provided. - required Optional[bool]: Explicitly set if the header is not required. - alias (Optional[str]): The alias of the header. This is the actual - header name (e.g., "X-API-Key"). + If not set (or set to `Undefined`), the header is required. + alias (Optional[str]): The alias of the header (the actual + header name, e.g., "X-API-Key"). convert_underscores (bool): If `True` (default), underscores in the variable name will be converted to hyphens in the header name. pattern (Optional[str]): A regex pattern string that the header value must match. - format (Optional[str]): A predefined format name (e.g., "uuid4", - "email", "bearer_token") that the header value must match. + format (Optional[str]): A predefined format name (e.g., "uuid4"). Cannot be used with `pattern`. allowed_values (Optional[List[str]]): A list of exact string values that are allowed for the header. - validator (Optional[Callable[[str], bool]]): A custom callable that - receives the header value and returns `True` if valid, or - `False` (or raises an Exception) if invalid. + validators (Optional[List[Callable]]): A list of custom validation + functions (sync or async) that receive the header value. title (Optional[str]): A title for the header in OpenAPI docs. description (Optional[str]): A description for the header in OpenAPI docs. - **header_kwargs (Any): Additional keyword arguments passed to the - parent `BaseValidator` (for error handling) and the - underlying `fastapi.Header` dependency. - Includes `status_code` (default 400) and `error_detail` - (default "Header Validation Failed") for error responses. - - Raises: - ValueError: If both `pattern` and `format` are specified, or if - an unknown `format` name is provided. + on_required_error_detail (str): Error message if header is missing. + on_pattern_error_detail (str): Error message if pattern/format fails. + on_allowed_values_error_detail (str): Error message if value not allowed. + on_custom_validator_error_detail (str): Error message if custom validator fails. + status_code (int): The default HTTP status code for validation errors. + error_detail (str): A generic fallback error message. + **header_kwargs (Any): Additional keyword arguments passed to FastAPI's Header(). """ - header_kwargs["status_code"] = header_kwargs.get("status_code", 400) - header_kwargs["error_detail"] = header_kwargs.get( - "error_detail", "Header Validation Failed" - ) - # Call super() with default error handling - super().__init__(**header_kwargs) - self._required = required + super().__init__(status_code=status_code, error_detail=error_detail, validators=validators) + + # Store "required" status based on the default value + self._is_required = default is Undefined # Store validation rules self._allowed_values = allowed_values - self._custom_validator = validator + self._custom_validators: list[Callable[..., Any]] = validators or [] - # Define type hints for attributes - self._pattern: Optional[Pattern[str]] = None - self._format_name: Optional[str] = None + # Store error messages + self._on_required_error_detail = on_required_error_detail + self._on_pattern_error_detail = on_pattern_error_detail + self._on_allowed_values_error_detail = on_allowed_values_error_detail + self._on_custom_validator_error_detail = on_custom_validator_error_detail + + self._pattern_str: Optional[str] = None + self._compiled_pattern: Optional[Pattern[str]] = None - # Handle pattern and format keys if pattern and format: - raise ValueError("Cannot specify both 'pattern' and 'format'. Choose one.") + raise ValueError("Cannot specify both 'pattern' and 'format'.") if format: - if format not in _FORMAT_PATTERNS: + self._pattern_str = _FORMAT_PATTERNS.get(format) + if self._pattern_str is None: raise ValueError( - f"Unknown format '{format}'. " - f"Available formats: {', '.join(_FORMAT_PATTERNS.keys())}" + f"Unknown format '{format}'. Available: {list(_FORMAT_PATTERNS.keys())}" ) - self._pattern = re.compile(_FORMAT_PATTERNS[format], re.IGNORECASE) - self._format_name = format + # Use IGNORECASE for format matching (e.g., UUIDs) + self._compiled_pattern = re.compile(self._pattern_str, re.IGNORECASE) elif pattern: - self._pattern = re.compile(pattern) - self._format_name = None - else: - self._pattern = None - self._format_name = None + self._pattern_str = pattern + self._compiled_pattern = re.compile(self._pattern_str) + + # We pass `None` if the header is required (default=Undefined) + # to bypass FastAPI's default 422, allowing our validator to run + # and use the custom error message. + fastapi_header_default = None if self._is_required else default - # Store the underlying FastAPI Header parameter self._header_param = Header( - default, + fastapi_header_default, alias=alias, convert_underscores=convert_underscores, title=title, @@ -159,57 +180,94 @@ def __init__( **header_kwargs, ) - def __call__(self, header_value: Optional[str] = None) -> Any: + # Dynamically set the __call__ method's signature so FastAPI recognizes + # the Header() dependency and injects the header value correctly. + # This is necessary because we need to pass self._header_param as the default, + # which isn't available at class definition time. + self._set_call_signature() + + def _set_call_signature(self) -> None: + """ + Sets the __call__ method's signature so FastAPI's dependency injection + system recognizes the Header() parameter and extracts the header value. + """ + + # Create a new signature with self and header_value parameters + # The header_value parameter has self._header_param as its default + # so FastAPI will use Header() to extract it from the request + sig = Signature( + [ + Parameter("self", Parameter.POSITIONAL_OR_KEYWORD), + Parameter( + "header_value", + Parameter.KEYWORD_ONLY, + default=self._header_param, + annotation=Optional[str], + ), + ] + ) + + # Set the signature on the underlying function, not the bound method + # Access the function object from the method + self.__call__.__func__.__signature__ = sig # type: ignore + + async def __call__(self, header_value: Optional[str] = None) -> Optional[str]: """ FastAPI dependency entry point for header validation. + FastAPI automatically injects the header value by recognizing the + Header() dependency in the method signature (set via _set_call_signature). + This method then validates the extracted header value and returns it + or raises an HTTPException with a custom error message. + Args: - header_value: The header value extracted from the request. + header_value: The header value extracted from the request by FastAPI. + Will be None if the header is not present. Returns: - The validated header value. + Optional[str]: The validated header value, or None if the header is + optional and not present. Raises: - HTTPException: If validation fails. + HTTPException: If validation fails, with the configured status code + and error message. """ - # If value is None, return a dependency that FastAPI will use - if header_value is None: - - def dependency(value: Optional[str] = self._header_param) -> Optional[str]: - return self._validate(value) - - return dependency - - # If value is provided (for testing), validate directly - return self._validate(header_value) + try: + # Validate the header value (which FastAPI injected via Header()) + return await self._validate(header_value) + except ValidationError as e: + # Convert our internal error to an HTTPException + self._raise_error(status_code=e.status_code, detail=str(e.detail)) + return None # pragma: no cover (unreachable) - def _validate(self, value: Optional[str]) -> Optional[str]: + async def _validate(self, value: Optional[str]) -> Optional[str]: """ - Runs all validation checks on the header value. + Runs all configured validation checks on the header value. + + Checks if the header is required, validates allowed values, pattern matching, + and custom validators in sequence. Args: - value: The header value to validate. + value: The header value to validate (None if not present). Returns: - The validated value. + Optional[str]: The validated header value, or None if optional and not present. Raises: - HTTPException: If any validation check fails. + ValidationError: If any validation check fails. """ - try: - self._validate_required(value) - except ValidationError as e: - self._raise_error(value=value, status_code=e.status_code, detail=str(e.detail)) - if value is None or value == "": - return value or "" - try: - self._validate_allowed_values(value) - self._validate_pattern(value) - self._validate_custom(value) + # 1. Check if required and not present + self._validate_required(value) - except ValidationError as e: - # Convert ValidationError to HTTPException - self._raise_error(value=value, status_code=e.status_code, detail=str(e.detail)) + # 2. If optional and not present, return None + # (It passed _validate_required, so if value is None, it's optional) + if value is None: + return None + + # 3. Run all other validations on the present value + self._validate_allowed_values(value) + self._validate_pattern(value) + await self._validate_custom(value) return value @@ -218,81 +276,59 @@ def _validate_required(self, value: Optional[str]) -> None: Checks if the header is present when required. Args: - value: The header value to check. + value: The header value (None if not present). + + Returns: + None Raises: - ValidationError: If the header is required but missing. + ValidationError: If the header is required but missing or empty. """ - if self._required and (value is None or value == ""): - detail = "Required header is missing." - if callable(detail): - detail_str = detail(value) - else: - detail_str = str(detail) - - raise ValidationError(detail=detail_str, status_code=400) + if self._is_required and (value is None or value == ""): + raise ValidationError( + detail=self._on_required_error_detail, status_code=self._status_code + ) def _validate_allowed_values(self, value: str) -> None: """ - Checks if the value is in the list of allowed values. + Checks if the header value is in the list of allowed values. Args: - value: The header value to check. + value: The header value to validate. + + Returns: + None Raises: - ValidationError: If the value is not in allowed_values. + ValidationError: If the value is not in the allowed list. """ if self._allowed_values is None: - return # No validation rule set + return if value not in self._allowed_values: detail = ( - f"Header value '{value}' is not allowed. " + f"{self._on_allowed_values_error_detail} " f"Allowed values are: {', '.join(self._allowed_values)}" ) - raise ValidationError(detail=detail, status_code=400) + raise ValidationError(detail=detail, status_code=self._status_code) def _validate_pattern(self, value: str) -> None: """ - Checks if the header value matches the required regex pattern. + Checks if the header value matches the configured regex pattern. Args: - value: The header value to check. - - Raises: - ValidationError: If the value doesn't match the pattern. - """ - if self._pattern is None: - return # No validation rule set - - if not self._pattern.match(value): - if self._format_name: - detail = f"Header value does not match the required format: '{self._format_name}'" - else: - detail = ( - f"Header value '{value}' does not match the required pattern: " - f"{self._pattern.pattern}" - ) - raise ValidationError(detail=detail, status_code=400) - - def _validate_custom(self, value: str) -> None: - """ - Runs a custom validation function if provided. + value: The header value to validate. - Args: - value: The header value to check. + Returns: + None Raises: - ValidationError: If the custom validator returns False or raises an exception. + ValidationError: If the value doesn't match the pattern. """ - if self._custom_validator is None: - return # No custom validator set + if self._compiled_pattern is None: + return - try: - if not self._custom_validator(value): - detail = f"Custom validation failed for header value '{value}'" - raise ValidationError(detail=detail, status_code=400) - except Exception as e: - # If the validator itself raises an exception, catch it - detail = f"Custom validation error: {str(e)}" - raise ValidationError(detail=detail, status_code=400) + if not self._compiled_pattern.fullmatch(value): + raise ValidationError( + detail=self._on_pattern_error_detail, status_code=self._status_code + ) diff --git a/fastapi_assets/request_validators/path_validator.py b/fastapi_assets/request_validators/path_validator.py index 363ec4e..135bd86 100644 --- a/fastapi_assets/request_validators/path_validator.py +++ b/fastapi_assets/request_validators/path_validator.py @@ -1,289 +1,194 @@ """Module providing the PathValidator for validating path parameters in FastAPI.""" -import re + from typing import Any, Callable, List, Optional, Union -from fastapi import Depends, Path -from fastapi_assets.core.base_validator import BaseValidator, ValidationError +from inspect import Signature, Parameter +from fastapi import Path +from fastapi_assets.core import BaseValidator, ValidationError class PathValidator(BaseValidator): r""" - A general-purpose dependency for validating path parameters in FastAPI. + A dependency factory for adding custom validation to FastAPI path parameters. + + This class extends the functionality of FastAPI's `Path()` by adding + support for `allowed_values` and custom `validators`. - It validates path parameters with additional constraints like allowed values, - regex patterns, string length checks, numeric bounds, and custom validators. + It acts as a factory: you instantiate it, and then *call* the + instance inside `Depends()` to get the actual dependency. + Example: .. code-block:: python - from fastapi import FastAPI - from fastapi_assets.path_validator import PathValidator + + from fastapi import FastAPI, Depends + from fastapi_assets.request_validators import PathValidator app = FastAPI() - # Create reusable validators + # 1. Create reusable validator *instances* item_id_validator = PathValidator( + "item_id", + _type=int, gt=0, lt=1000, - error_detail="Item ID must be between 1 and 999" ) username_validator = PathValidator( + "username", + _type=str, min_length=5, max_length=15, pattern=r"^[a-zA-Z0-9]+$", - error_detail="Username must be 5-15 alphanumeric characters" ) @app.get("/items/{item_id}") - def get_item(item_id: int = item_id_validator): + def get_item(item_id: int = Depends(item_id_validator())): return {"item_id": item_id} @app.get("/users/{username}") - def get_user(username: str = username_validator): + def get_user(username: str = Depends(username_validator())): return {"username": username} """ def __init__( self, + param_name: str, + _type: type, default: Any = ..., *, + # Custom validation rules allowed_values: Optional[List[Any]] = None, - pattern: Optional[str] = None, - min_length: Optional[int] = None, - max_length: Optional[int] = None, + validators: Optional[List[Callable[[Any], Any]]] = None, + on_custom_validator_error_detail: str = "Custom validation failed.", + # Standard Path() parameters + title: Optional[str] = None, + description: Optional[str] = None, gt: Optional[Union[int, float]] = None, lt: Optional[Union[int, float]] = None, ge: Optional[Union[int, float]] = None, le: Optional[Union[int, float]] = None, - validator: Optional[Callable[[Any], bool]] = None, - # Standard Path() parameters - title: Optional[str] = None, - description: Optional[str] = None, - alias: Optional[str] = None, + min_length: Optional[int] = None, + max_length: Optional[int] = None, + pattern: Optional[str] = None, deprecated: Optional[bool] = None, - **path_kwargs : Any + **path_kwargs: Any, ) -> None: """ - Initializes the PathValidator. + Initializes the PathValidator factory. Args: - default: Default value for the path parameter (usually ... for required). - allowed_values: List of allowed values for the parameter. - pattern: Regex pattern the parameter must match (for strings). - min_length: Minimum length for string parameters. - max_length: Maximum length for string parameters. - gt: Value must be greater than this (for numeric parameters). - lt: Value must be less than this (for numeric parameters). - ge: Value must be greater than or equal to this. - le: Value must be less than or equal to this. - validator: Custom validation function that takes the value and returns bool. + param_name: The exact name of the path parameter. + _type: The Python type for coercion (e.g., int, str, UUID). + default: Default value for the path parameter. + allowed_values: List of allowed values. + validators: List of custom validation functions. + on_custom_validator_error_detail: Error message for custom validators. title: Title for API documentation. description: Description for API documentation. - alias: Alternative parameter name. + gt: Value must be greater than this. + lt: Value must be less than this. + ge: Value must be greater than or equal to this. + le: Value must be less than or equal to this. + min_length: Minimum length for string parameters. + max_length: Maximum length for string parameters. + pattern: Regex pattern the parameter must match. deprecated: Whether the parameter is deprecated. **path_kwargs: Additional arguments passed to FastAPI's Path(). """ - path_kwargs["error_detail"] = path_kwargs.get("error_detail", "Path parameter validation failed.") - path_kwargs["status_code"] = path_kwargs.get("status_code", 400) - # Call super() with default error handling + path_kwargs.setdefault("error_detail", "Path parameter validation failed.") + path_kwargs.setdefault("status_code", 400) + super().__init__( - **path_kwargs + status_code=path_kwargs["status_code"], + error_detail=path_kwargs["error_detail"], + validators=validators, ) - # Store validation rules + + self._param_name = param_name + self._type = _type self._allowed_values = allowed_values - self._pattern = re.compile(pattern) if pattern else None - self._min_length = min_length - self._max_length = max_length - self._gt = gt - self._lt = lt - self._ge = ge - self._le = le - self._custom_validator = validator + self._on_custom_validator_error_detail = on_custom_validator_error_detail - # Store the underlying FastAPI Path parameter - # This preserves all standard Path() features (title, description, etc.) self._path_param = Path( default, title=title, description=description, - alias=alias, deprecated=deprecated, gt=gt, lt=lt, ge=ge, le=le, - **path_kwargs + min_length=min_length, + max_length=max_length, + pattern=pattern, + **path_kwargs, ) - def __call__(self, value: Any = None) -> Any: - """ - FastAPI dependency entry point for path validation. - - Args: - value: The path parameter value extracted from the URL. + def __call__(self) -> Callable[..., Any]: + """ + This is the factory method. + It generates and returns the dependency function + that FastAPI will use. + """ + + async def dependency(**kwargs: Any) -> Any: + path_value = kwargs[self._param_name] + try: + validated_value = await self._validate(path_value) + return validated_value + except ValidationError as e: + self._raise_error(path_value, status_code=e.status_code, detail=e.detail) + return None + + sig = Signature( + [ + Parameter( + self._param_name, + Parameter.KEYWORD_ONLY, + default=self._path_param, + annotation=self._type, + ) + ] + ) - Returns: - The validated path parameter value. + dependency.__signature__ = sig # type: ignore + return dependency - Raises: - HTTPException: If validation fails. + async def _validate(self, value: Any) -> Any: """ - # If value is None, it means FastAPI will inject the actual path parameter - # This happens because FastAPI handles the Path() dependency internally - if value is None: - # Return a dependency that FastAPI will use - async def dependency(param_value: Any = self._path_param) -> Any: - return self._validate(param_value) - return Depends(dependency) + Runs all validation checks on the path parameter value. - # If value is provided (for testing), validate directly - return self._validate(value) - - def _validate(self, value: Any) -> Any: - """ - Runs all validation checks on the parameter value. + Executes allowed values checking and custom validator checking in sequence. Args: value: The path parameter value to validate. Returns: - The validated value. + Any: The validated value (unchanged if validation passes). Raises: - HTTPException: If any validation check fails. + ValidationError: If any validation check fails. """ - try: - self._validate_allowed_values(value) - self._validate_pattern(value) - self._validate_length(value) - self._validate_numeric_bounds(value) - self._validate_custom(value) - except ValidationError as e: - # Convert ValidationError to HTTPException - self._raise_error( - status_code=e.status_code, - detail=str(e.detail) - ) - + self._validate_allowed_values(value) + await self._validate_custom(value) return value def _validate_allowed_values(self, value: Any) -> None: """ - Checks if the value is in the list of allowed values. + Checks if the path parameter value is in the list of allowed values. Args: - value: The parameter value to check. + value: The value to validate. + + Returns: + None Raises: - ValidationError: If the value is not in allowed_values. + ValidationError: If the value is not in the allowed values list. """ if self._allowed_values is None: - return # No validation rule set + return if value not in self._allowed_values: - detail = ( - f"Value '{value}' is not allowed. " - f"Allowed values are: {', '.join(map(str, self._allowed_values))}" - ) + allowed_str = ", ".join(map(str, self._allowed_values)) + detail = f"Value '{value}' is not allowed. Allowed values are: {allowed_str}" raise ValidationError(detail=detail, status_code=400) - - def _validate_pattern(self, value: Any) -> None: - """ - Checks if the string value matches the required regex pattern. - - Args: - value: The parameter value to check. - - Raises: - ValidationError: If the value doesn't match the pattern. - """ - if self._pattern is None: - return # No validation rule set - - if not isinstance(value, str): - return # Pattern validation only applies to strings - - if not self._pattern.match(value): - detail = ( - f"Value '{value}' does not match the required pattern: " - f"{self._pattern.pattern}" - ) - raise ValidationError(detail=detail, status_code=400) - - def _validate_length(self, value: Any) -> None: - """ - Checks if the string length is within the specified bounds. - - Args: - value: The parameter value to check. - - Raises: - ValidationError: If the length is out of bounds. - """ - if not isinstance(value, str): - return # Length validation only applies to strings - - value_len = len(value) - - if self._min_length is not None and value_len < self._min_length: - detail = ( - f"Value '{value}' is too short. " - f"Minimum length is {self._min_length} characters." - ) - raise ValidationError(detail=detail, status_code=400) - - if self._max_length is not None and value_len > self._max_length: - detail = ( - f"Value '{value}' is too long. " - f"Maximum length is {self._max_length} characters." - ) - raise ValidationError(detail=detail, status_code=400) - - def _validate_numeric_bounds(self, value: Any) -> None: - """ - Checks if numeric values satisfy gt, lt, ge, le constraints. - - Args: - value: The parameter value to check. - - Raises: - ValidationError: If the value is out of the specified bounds. - """ - if not isinstance(value, (int, float)): - return # Numeric validation only applies to numbers - - if self._gt is not None and value <= self._gt: - detail = f"Value must be greater than {self._gt}" - raise ValidationError(detail=detail, status_code=400) - - if self._lt is not None and value >= self._lt: - detail = f"Value must be less than {self._lt}" - raise ValidationError(detail=detail, status_code=400) - - if self._ge is not None and value < self._ge: - detail = f"Value must be greater than or equal to {self._ge}" - raise ValidationError(detail=detail, status_code=400) - - if self._le is not None and value > self._le: - detail = f"Value must be less than or equal to {self._le}" - raise ValidationError(detail=detail, status_code=400) - - def _validate_custom(self, value: Any) -> None: - """ - Runs a custom validation function if provided. - - Args: - value: The parameter value to check. - - Raises: - ValidationError: If the custom validator returns False or raises an exception. - """ - if self._custom_validator is None: - return # No custom validator set - - try: - if not self._custom_validator(value): - detail = f"Custom validation failed for value '{value}'" - raise ValidationError(detail=detail, status_code=400) - except Exception as e: - # If the validator itself raises an exception, catch it - detail = f"Custom validation error: {str(e)}" - raise ValidationError(detail=detail, status_code=400) \ No newline at end of file diff --git a/fastapi_assets/validators/__init__.py b/fastapi_assets/validators/__init__.py index 264e724..9854383 100644 --- a/fastapi_assets/validators/__init__.py +++ b/fastapi_assets/validators/__init__.py @@ -1 +1,5 @@ """Module for file based validation in FastAPI Assets.""" + +from fastapi_assets.validators.csv_validator import CSVValidator +from fastapi_assets.validators.file_validator import FileValidator +from fastapi_assets.validators.image_validator import ImageValidator diff --git a/fastapi_assets/validators/csv_validator.py b/fastapi_assets/validators/csv_validator.py index cc0f9f2..c8860ee 100644 --- a/fastapi_assets/validators/csv_validator.py +++ b/fastapi_assets/validators/csv_validator.py @@ -5,7 +5,7 @@ from starlette.datastructures import UploadFile as StarletteUploadFile # Import from base file_validator module -from fastapi_assets.core.base_validator import ValidationError +from fastapi_assets.core import ValidationError from fastapi_assets.validators.file_validator import ( FileValidator, ) @@ -29,7 +29,7 @@ class CSVValidator(FileValidator): .. code-block:: python from fastapi import FastAPI, UploadFile, Depends - from fastapi_assets.validators.csv_validator import CSVValidator + from fastapi_assets.validators import CSVValidator app = FastAPI() @@ -121,7 +121,7 @@ def __init__( self._row_error_detail = on_row_error_detail self._parse_error_detail = on_parse_error_detail - async def __call__(self, file: UploadFile = File(...), **kwargs: Any) -> StarletteUploadFile: + async def __call__(self, file: UploadFile = File(...)) -> StarletteUploadFile: """ FastAPI dependency entry point for CSV validation. @@ -137,35 +137,66 @@ async def __call__(self, file: UploadFile = File(...), **kwargs: Any) -> Starlet # Run all parent validations (size, content-type, filename) # This will also rewind the file (await file.seek(0)) try: - await super().__call__(file, **kwargs) - except ValidationError as e: - # Re-raise parent's validation error - self._raise_error(status_code=e.status_code, detail=str(e.detail)) - - # File is validated by parent and rewound. Start CSV checks. - try: - # Check encoding if specified - await self._validate_encoding(file) - await file.seek(0) # Rewind after encoding check - - # Check columns and row counts - await self._validate_csv_structure(file) - + await self._validate(file=file) except ValidationError as e: await file.close() + # Re-raise parent's validation error self._raise_error(status_code=e.status_code, detail=str(e.detail)) except Exception as e: # Catch pandas errors (e.g., CParserError, UnicodeDecodeError) await file.close() detail = self._parse_error_detail or f"Failed to parse CSV file: {e}" self._raise_error(status_code=400, detail=detail) + try: + # CRITICAL: Rewind the file AGAIN so the endpoint can read it. + await file.seek(0) + return file + except Exception as e: + await file.close() + self._raise_error( + status_code=e.status_code if hasattr(e, "status_code") else 400, + detail="File could not be rewound after validation.", + ) + return None # type: ignore # pragma: no cover - # CRITICAL: Rewind the file AGAIN so the endpoint can read it. + async def _validate(self, file: UploadFile) -> None: + """ + Runs all CSV-specific validation checks on the uploaded file. + + This method orchestrates the validation pipeline: first calls parent + FileValidator validations, then validates encoding, and finally + validates the CSV structure (columns and rows). + + Args: + file: The uploaded file to validate. + + Returns: + None + + Raises: + ValidationError: If any validation check fails. + """ + await super()._validate(file) + await self._validate_encoding(file) await file.seek(0) - return file + await self._validate_csv_structure(file) async def _validate_encoding(self, file: UploadFile) -> None: - """Checks if the file encoding matches one of the allowed encodings.""" + """ + Validates that the file encoding matches one of the allowed encodings. + + Reads a small chunk of the file and attempts to decode it with each + specified encoding. If none match, raises a ValidationError. + + Args: + file: The uploaded file to validate. + + Returns: + None + + Raises: + ValidationError: If the file encoding is not one of the allowed encodings. + """ if not self._encoding: return # No check needed @@ -190,13 +221,22 @@ async def _validate_encoding(self, file: UploadFile) -> None: raise ValidationError(detail=str(detail), status_code=400) def _check_columns(self, header: List[str]) -> None: - """Validates the CSV header against column rules. + """ + Validates the CSV header against configured column rules. + + Checks for required columns, disallowed columns, and exact column matching + based on the validator's configuration. Raises ValidationError if any + rule is violated. + Args: - header: List of column names from the CSV header. + header: List of column names extracted from the CSV header row. + Returns: None + Raises: - ValidationError: If any column validation fails. + ValidationError: If exact columns don't match, required columns are missing, + or disallowed columns are present. """ header_set = set(header) @@ -229,7 +269,22 @@ def _check_columns(self, header: List[str]) -> None: raise ValidationError(detail=str(detail), status_code=400) def _check_row_counts(self, total_rows: int) -> None: - """Validates the total row count against min/max rules.""" + """ + Validates that the CSV row count meets min/max constraints. + + Compares the actual number of data rows against the configured + minimum and maximum row limits. Raises ValidationError if constraints + are violated. + + Args: + total_rows: The total number of data rows (excluding header) in the CSV. + + Returns: + None + + Raises: + ValidationError: If the row count is below minimum or exceeds maximum. + """ if self._min_rows is not None and total_rows < self._min_rows: detail = self._row_error_detail or ( f"File does not meet minimum required rows: {self._min_rows}. Found: {total_rows}." @@ -244,16 +299,21 @@ def _check_row_counts(self, total_rows: int) -> None: async def _validate_csv_structure(self, file: UploadFile) -> None: """ - Validates the CSV columns and row counts using pandas. + Validates the CSV structure including columns and row counts using pandas. + + This method handles both efficient bounded reads (checking only necessary rows) + and full file reads depending on the `header_check_only` setting. It validates + column constraints first, then row count constraints if applicable. - Uses either an efficient bounded read (header_check_only=True) - or a full stream (header_check_only=False) for row counts. Args: file: The uploaded CSV file to validate. + Returns: None + Raises: - ValidationError: If any structure validation fails. + ValidationError: If column validation fails, row count validation fails, + or if the file cannot be parsed as valid CSV. """ # file.file is the underlying SpooledTemporaryFile file_obj = file.file diff --git a/fastapi_assets/validators/file_validator.py b/fastapi_assets/validators/file_validator.py index 0cbd06d..178c6d8 100644 --- a/fastapi_assets/validators/file_validator.py +++ b/fastapi_assets/validators/file_validator.py @@ -33,7 +33,7 @@ class FileValidator(BaseValidator): .. code-block:: python from fastapi import FastAPI, UploadFile, Depends - from fastapi_assets.validators.file_validator import FileValidator + from fastapi_assets.validators import FileValidator app = FastAPI() @@ -64,6 +64,7 @@ def __init__( on_size_error_detail: Optional[Union[str, Callable[[Any], str]]] = None, on_type_error_detail: Optional[Union[str, Callable[[Any], str]]] = None, on_filename_error_detail: Optional[Union[str, Callable[[Any], str]]] = None, + validators: Optional[List[Callable]] = None, **kwargs: Any, ): """ @@ -83,7 +84,11 @@ def __init__( # by the specific error handlers. kwargs["error_detail"] = kwargs.get("error_detail", "File validation failed.") kwargs["status_code"] = 400 - super().__init__(**kwargs) + super().__init__( + error_detail=kwargs["error_detail"], + status_code=kwargs["status_code"], + validators=validators, + ) # Parse sizes once self._max_size = ( @@ -106,28 +111,31 @@ def __init__( self._type_error_detail = on_type_error_detail self._filename_error_detail = on_filename_error_detail - async def __call__(self, file: UploadFile = File(...), **kwargs: Any) -> StarletteUploadFile: + async def __call__(self, file: UploadFile = File(...)) -> StarletteUploadFile: """ FastAPI dependency entry point for file validation. + + Runs all configured validation checks on the uploaded file (content type, + filename, size, and custom validators) and returns the validated file + after rewinding it so the endpoint can read it from the beginning. + Args: file: The uploaded file to validate. + Returns: - The validated UploadFile object. + StarletteUploadFile: The validated UploadFile object, rewound to the start. + Raises: - HTTPException: If validation fails. + HTTPException: If any validation check fails. """ try: - self._validate_content_type(file) - self._validate_filename(file) - await self._validate_size(file) - # Additional validations can be added here + await self._validate(file=file) except ValidationError as e: # Our custom validation exception, convert to HTTPException self._raise_error(status_code=e.status_code, detail=str(e.detail)) except Exception as e: # Catch any other unexpected error during validation await file.close() - print("Raising HTTPException for unexpected error:", e) self._raise_error( status_code=400, detail="An unexpected error occurred during file validation.", @@ -138,14 +146,42 @@ async def __call__(self, file: UploadFile = File(...), **kwargs: Any) -> Starlet await file.seek(0) return file + async def _validate(self, file: UploadFile) -> None: + """ + Runs all file validation checks in sequence. + + Executes content-type, filename, size, and custom validator checks + on the uploaded file. + + Args: + file: The uploaded file to validate. + + Returns: + None + + Raises: + ValidationError: If any validation check fails. + """ + self._validate_content_type(file) + self._validate_filename(file) + await self._validate_size(file=file) + await self._validate_custom(value=file) + def _validate_content_type(self, file: UploadFile) -> None: - """Checks the file's MIME type. + """ + Validates that the file's MIME type is in the allowed list. + + Checks the file's Content-Type against the configured allowed types, + supporting wildcard patterns (e.g., "image/*"). + Args: file: The uploaded file to validate. + Returns: None + Raises: - ValidationError: If the content type is not allowed. + ValidationError: If the content type is not in the allowed list. """ if not self._content_types: return # No validation rule set @@ -156,12 +192,22 @@ def _validate_content_type(self, file: UploadFile) -> None: f"File has an unsupported media type: '{file_type}'. " f"Allowed types are: {', '.join(self._content_types)}" ) - print("Raising ValidationError for content type:", detail) # Use 415 for Unsupported Media Type raise ValidationError(detail=str(detail), status_code=415) def _validate_filename(self, file: UploadFile) -> None: - """Checks the file's name against a regex pattern.""" + """ + Validates that the filename matches the configured regex pattern. + + Args: + file: The uploaded file to validate. + + Returns: + None + + Raises: + ValidationError: If the filename doesn't match the pattern or is missing. + """ if not self._filename_regex: return # No validation rule set @@ -173,8 +219,19 @@ def _validate_filename(self, file: UploadFile) -> None: async def _validate_size(self, file: UploadFile) -> None: """ - Checks file size, using Content-Length if available, - or streaming and counting if not. + Validates that the file size is within configured bounds. + + Uses the Content-Length header if available for efficiency, otherwise + streams the file to determine its actual size. + + Args: + file: The uploaded file to validate. + + Returns: + None + + Raises: + ValidationError: If the file size exceeds max_size or is below min_size. """ if self._max_size is None and self._min_size is None: return # No validation rule set diff --git a/fastapi_assets/validators/image_validator.py b/fastapi_assets/validators/image_validator.py index af0c59d..8a8a4aa 100644 --- a/fastapi_assets/validators/image_validator.py +++ b/fastapi_assets/validators/image_validator.py @@ -3,7 +3,7 @@ """ from typing import Any, Callable, List, Optional, Union -from fastapi_assets.core.base_validator import ValidationError +from fastapi_assets.core import ValidationError from fastapi import File, UploadFile from starlette.datastructures import UploadFile as StarletteUploadFile from fastapi_assets.validators.file_validator import FileValidator @@ -41,7 +41,7 @@ class ImageValidator(FileValidator): .. code-block:: python from fastapi import FastAPI, UploadFile, Depends - from fastapi_assets.validators.image_validator import ImageValidator + from fastapi_assets.validators import ImageValidator app = FastAPI() @@ -149,7 +149,7 @@ def _parse_aspect_ratios(self, ratios: Optional[List[str]]) -> Optional[List[flo ) return parsed - async def __call__(self, file: UploadFile = File(...), **kwargs: Any) -> StarletteUploadFile: + async def __call__(self, file: UploadFile = File(...)) -> StarletteUploadFile: """ FastAPI dependency entry point for image validation. Args: @@ -157,24 +157,9 @@ async def __call__(self, file: UploadFile = File(...), **kwargs: Any) -> Starlet Returns: The validated UploadFile object. """ - # Run all parent validations (size, content-type, filename) - # This will also rewind the file stream to position 0. - try: - await super().__call__(file, **kwargs) - except ValidationError as e: - # Re-raise the exception from the parent - self._raise_error(status_code=e.status_code, detail=str(e.detail)) - # Run image-specific validations using Pillow - img = None try: - # `file.file` is a SpooledTemporaryFile, which Image.open can read. - img = Image.open(file.file) - - # Perform content-based validations - self._validate_format(img) - self._validate_resolution(img) - self._validate_aspect_ratio(img) + await self._validate(file) except (UnidentifiedImageError, IOError) as e: # Pillow couldn't identify it as an image, or file is corrupt @@ -196,23 +181,46 @@ async def __call__(self, file: UploadFile = File(...), **kwargs: Any) -> Starlet detail=f"An unexpected error occurred during image validation: {e}", ) finally: - if img: - img.close() - # CRITICAL: Rewind the file stream *again* so the endpoint # can read it after Pillow is done. await file.seek(0) return file + async def _validate(self, file: UploadFile) -> None: + """ + Runs all image validation checks using PIL/Pillow. + + Opens the image file with Pillow and validates its format, resolution, + and aspect ratio against the configured constraints. + + Args: + file: The uploaded image file to validate. + + Returns: + None + + Raises: + ValidationError: If any image validation check fails. + """ + await super()._validate(file) + img = Image.open(file.file) + self._validate_format(img) + self._validate_resolution(img) + self._validate_aspect_ratio(img) + def _validate_format(self, img: Image.Image) -> None: - """Checks the image's actual format (e.g., 'JPEG', 'PNG'). + """ + Validates that the image format is in the allowed list. + Args: img: The opened PIL Image object. + Returns: None + Raises: - ValidationError: If the image format is not allowed. + ValidationError: If the image format is not in the allowed list. """ if not self._allowed_formats: return # No rule set @@ -227,13 +235,19 @@ def _validate_format(self, img: Image.Image) -> None: raise ValidationError(detail=str(detail), status_code=415) def _validate_resolution(self, img: Image.Image) -> None: - """Checks image dimensions against min, max, and exact constraints. + """ + Validates the image's resolution against min, max, and exact constraints. + + Checks that the image width and height meet the configured constraints. + Args: img: The opened PIL Image object. + Returns: None + Raises: - ValidationError: If the image resolution is out of bounds + ValidationError: If the image resolution does not meet constraints. """ if not (self._min_resolution or self._max_resolution or self._exact_resolution): return # No resolution rules set @@ -265,13 +279,20 @@ def _validate_resolution(self, img: Image.Image) -> None: raise ValidationError(detail=str(detail), status_code=400) def _validate_aspect_ratio(self, img: Image.Image) -> None: - """Checks the image's aspect ratio against a list of allowed ratios. + """ + Validates that the image's aspect ratio is in the allowed list. + + Compares the actual aspect ratio against configured allowed ratios with + a tolerance for floating-point precision. + Args: img: The opened PIL Image object. + Returns: None + Raises: - ValidationError: If the image's aspect ratio is not allowed. + ValidationError: If the image aspect ratio is not allowed or cannot be calculated. """ if not self._aspect_ratios: return # No rule set diff --git a/tests/test_base_validator.py b/tests/test_base_validator.py index 7e0a88c..1cd65f2 100644 --- a/tests/test_base_validator.py +++ b/tests/test_base_validator.py @@ -7,7 +7,7 @@ from fastapi_assets.core.exceptions import ValidationError -# --- Test Setup --- +# Test Setup class _MockValidator(BaseValidator): @@ -23,6 +23,10 @@ def __call__(self, *args: Any, **kwargs: Any) -> Any: """Minimal implementation to satisfy the abstract contract.""" pass + def _validate(self, value: Any) -> None: + """Minimal implementation to satisfy the abstract contract.""" + pass + def public_raise_error( self, value: Any, @@ -35,7 +39,7 @@ def public_raise_error( self._raise_error(value=value, status_code=status_code, detail=detail) -# --- Test Cases --- +# Test Cases def test_cannot_instantiate_abstract_class() -> None: diff --git a/tests/test_cookie_validator.py b/tests/test_cookie_validator.py index 664416d..8a7be6f 100644 --- a/tests/test_cookie_validator.py +++ b/tests/test_cookie_validator.py @@ -10,19 +10,19 @@ # Import the class to be tested try: - from fastapi_assets.request_validators.cookie_validator import CookieAssert + from fastapi_assets.request_validators.cookie_validator import CookieValidator as CookieAssert from fastapi_assets.core.exceptions import ValidationError except ImportError as e: pytest.skip(f"Could not import CookieAssert: {e}", allow_module_level=True) -# --- Test Application Setup --- +# Test Application Setup # Define validators once, as they would be in a real app validate_required_uuid = CookieAssert( alias="session-id", format="uuid4", on_required_error_detail="Session is required.", - on_pattern_error_detail="Invalid session format." + on_pattern_error_detail="Invalid session format.", ) validate_optional_gt10 = CookieAssert( @@ -31,33 +31,29 @@ default=None, gt=10, on_comparison_error_detail="Tracker must be > 10.", - on_numeric_error_detail="Tracker must be a number." + on_numeric_error_detail="Tracker must be a number.", ) validate_length_5 = CookieAssert( - alias="code", - min_length=5, - max_length=5, - on_length_error_detail="Code must be 5 chars." + alias="code", min_length=5, max_length=5, on_length_error_detail="Code must be 5 chars." ) + def _custom_check(val: str): """A sample custom validator function""" if val not in ["admin", "user"]: - raise ValueError("Role is invalid") - return True + raise ValidationError("Role is invalid") + validate_custom_role = CookieAssert( - alias="role", - validators=[_custom_check], - on_validator_error_detail="Invalid role." + alias="role", validators=[_custom_check], on_validator_error_detail="Invalid role." ) # Additional validators for extended tests validate_bearer_token = CookieAssert( alias="auth-token", format="bearer_token", - on_pattern_error_detail="Invalid bearer token format." + on_pattern_error_detail="Invalid bearer token format.", ) validate_numeric_ge_le = CookieAssert( @@ -65,71 +61,82 @@ def _custom_check(val: str): ge=0, le=100, on_comparison_error_detail="Score must be between 0 and 100.", - on_numeric_error_detail="Score must be a number." + on_numeric_error_detail="Score must be a number.", ) + def _async_validator(val: str): """An async custom validator function""" # This is actually a sync function that will be called # The CookieAssert supports both sync and async validators - return val.startswith("valid_") + if not val.startswith("valid_"): + raise ValidationError("Token must start with 'valid_'.") + validate_async_custom = CookieAssert( alias="async-token", validators=[_async_validator], - on_validator_error_detail="Token must start with 'valid_'." + on_validator_error_detail="Token must start with 'valid_'.", ) validate_email_format = CookieAssert( - alias="user-email", - format="email", - on_pattern_error_detail="Invalid email format." + alias="user-email", format="email", on_pattern_error_detail="Invalid email format." ) # Create a minimal FastAPI app for testing app = FastAPI() + @app.get("/test-required") async def get_required(session: str = Depends(validate_required_uuid)): """Test endpoint for a required, formatted cookie.""" return {"session": session} + @app.get("/test-optional") async def get_optional(tracker: Optional[float] = Depends(validate_optional_gt10)): """Test endpoint for an optional, numeric cookie.""" return {"tracker": tracker} + @app.get("/test-length") async def get_length(code: str = Depends(validate_length_5)): """Test endpoint for a length-constrained cookie.""" return {"code": code} + @app.get("/test-custom") async def get_custom(role: str = Depends(validate_custom_role)): """Test endpoint for a custom-validated cookie.""" return {"role": role} + @app.get("/test-bearer") async def get_bearer(token: str = Depends(validate_bearer_token)): """Test endpoint for bearer token format.""" return {"token": token} + @app.get("/test-ge-le") async def get_numeric_range(score: float = Depends(validate_numeric_ge_le)): """Test endpoint for numeric range validation.""" return {"score": score} + @app.get("/test-async-custom") async def get_async_custom(token: str = Depends(validate_async_custom)): """Test endpoint for async custom validator.""" return {"token": token} + @app.get("/test-email") async def get_email(email: str = Depends(validate_email_format)): """Test endpoint for email format.""" return {"email": email} -# --- Pytest Fixtures --- + +# Pytest Fixtures + @pytest.fixture(scope="module") def anyio_backend(): @@ -145,13 +152,12 @@ async def client(anyio_backend): Pytest fixture to create an AsyncClient for the test app. Depends on the 'anyio_backend' fixture. """ - async with AsyncClient( - transport=ASGITransport(app=app), - base_url="http://test" - ) as ac: + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: yield ac -# --- Test Cases --- + +# Test Cases + # REQUIRED COOKIE TESTS @pytest.mark.anyio @@ -161,6 +167,7 @@ async def test_required_cookie_missing(client: AsyncClient): assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.json() == {"detail": "Session is required."} + @pytest.mark.anyio async def test_required_cookie_invalid_format(client: AsyncClient): """Tests that a required cookie fails on invalid format.""" @@ -169,6 +176,7 @@ async def test_required_cookie_invalid_format(client: AsyncClient): assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.json() == {"detail": "Invalid session format."} + @pytest.mark.anyio async def test_required_cookie_valid(client: AsyncClient): """Tests that a required cookie passes with valid format.""" @@ -178,6 +186,7 @@ async def test_required_cookie_valid(client: AsyncClient): assert response.status_code == status.HTTP_200_OK assert response.json() == {"session": valid_uuid} + # OPTIONAL COOKIE TESTS @pytest.mark.anyio async def test_optional_cookie_missing(client: AsyncClient): @@ -186,6 +195,7 @@ async def test_optional_cookie_missing(client: AsyncClient): assert response.status_code == status.HTTP_200_OK assert response.json() == {"tracker": None} + @pytest.mark.anyio async def test_optional_cookie_invalid_comparison(client: AsyncClient): """Tests that an optional cookie fails numeric comparison.""" @@ -194,6 +204,7 @@ async def test_optional_cookie_invalid_comparison(client: AsyncClient): assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.json() == {"detail": "Tracker must be > 10."} + @pytest.mark.anyio async def test_optional_cookie_invalid_numeric(client: AsyncClient): """Tests that a numeric cookie fails non-numeric values.""" @@ -202,6 +213,7 @@ async def test_optional_cookie_invalid_numeric(client: AsyncClient): assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.json() == {"detail": "Tracker must be a number."} + @pytest.mark.anyio async def test_optional_cookie_valid(client: AsyncClient): """Tests that an optional cookie passes with a valid value.""" @@ -210,6 +222,7 @@ async def test_optional_cookie_valid(client: AsyncClient): assert response.status_code == status.HTTP_200_OK assert response.json() == {"tracker": 100.0} + @pytest.mark.anyio async def test_optional_cookie_boundary_gt(client: AsyncClient): """Tests boundary condition for gt comparison (10 is not > 10).""" @@ -218,6 +231,7 @@ async def test_optional_cookie_boundary_gt(client: AsyncClient): assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.json() == {"detail": "Tracker must be > 10."} + @pytest.mark.anyio async def test_optional_cookie_boundary_gt_valid(client: AsyncClient): """Tests boundary condition for gt comparison (10.1 is > 10).""" @@ -226,6 +240,7 @@ async def test_optional_cookie_boundary_gt_valid(client: AsyncClient): assert response.status_code == status.HTTP_200_OK assert response.json() == {"tracker": 10.1} + # LENGTH CONSTRAINT TESTS @pytest.mark.anyio async def test_length_cookie_too_short(client: AsyncClient): @@ -235,6 +250,7 @@ async def test_length_cookie_too_short(client: AsyncClient): assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.json() == {"detail": "Code must be 5 chars."} + @pytest.mark.anyio async def test_length_cookie_too_long(client: AsyncClient): """Tests max_length validation.""" @@ -243,6 +259,7 @@ async def test_length_cookie_too_long(client: AsyncClient): assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.json() == {"detail": "Code must be 5 chars."} + @pytest.mark.anyio async def test_length_cookie_valid(client: AsyncClient): """Tests valid length validation.""" @@ -251,6 +268,7 @@ async def test_length_cookie_valid(client: AsyncClient): assert response.status_code == status.HTTP_200_OK assert response.json() == {"code": "12345"} + @pytest.mark.anyio async def test_length_cookie_min_boundary(client: AsyncClient): """Tests minimum boundary condition.""" @@ -258,6 +276,7 @@ async def test_length_cookie_min_boundary(client: AsyncClient): response = await client.get("/test-length", cookies=cookies) assert response.status_code == status.HTTP_400_BAD_REQUEST + # CUSTOM VALIDATOR TESTS @pytest.mark.anyio async def test_custom_validator_fail(client: AsyncClient): @@ -265,7 +284,8 @@ async def test_custom_validator_fail(client: AsyncClient): cookies = {"role": "guest"} # "guest" is not in ["admin", "user"] response = await client.get("/test-custom", cookies=cookies) assert response.status_code == status.HTTP_400_BAD_REQUEST - assert "Invalid role." in response.json()["detail"] + assert "role is invalid" in response.json()["detail"].lower() + @pytest.mark.anyio async def test_custom_validator_pass_admin(client: AsyncClient): @@ -275,6 +295,7 @@ async def test_custom_validator_pass_admin(client: AsyncClient): assert response.status_code == status.HTTP_200_OK assert response.json() == {"role": "admin"} + @pytest.mark.anyio async def test_custom_validator_pass_user(client: AsyncClient): """Tests custom validator function success with 'user'.""" @@ -283,6 +304,7 @@ async def test_custom_validator_pass_user(client: AsyncClient): assert response.status_code == status.HTTP_200_OK assert response.json() == {"role": "user"} + # FORMAT PATTERN TESTS @pytest.mark.anyio async def test_bearer_token_valid_format(client: AsyncClient): @@ -291,6 +313,7 @@ async def test_bearer_token_valid_format(client: AsyncClient): response = await client.get("/test-bearer", cookies=cookies) assert response.status_code == status.HTTP_200_OK + @pytest.mark.anyio async def test_bearer_token_lowercase_bearer(client: AsyncClient): """Tests bearer token with lowercase 'bearer'.""" @@ -298,6 +321,7 @@ async def test_bearer_token_lowercase_bearer(client: AsyncClient): response = await client.get("/test-bearer", cookies=cookies) assert response.status_code == status.HTTP_200_OK + @pytest.mark.anyio async def test_bearer_token_invalid_no_bearer_prefix(client: AsyncClient): """Tests bearer token missing 'Bearer' prefix.""" @@ -306,6 +330,7 @@ async def test_bearer_token_invalid_no_bearer_prefix(client: AsyncClient): assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.json() == {"detail": "Invalid bearer token format."} + @pytest.mark.anyio async def test_email_valid_format(client: AsyncClient): """Tests valid email format.""" @@ -313,6 +338,7 @@ async def test_email_valid_format(client: AsyncClient): response = await client.get("/test-email", cookies=cookies) assert response.status_code == status.HTTP_200_OK + @pytest.mark.anyio async def test_email_with_plus_sign(client: AsyncClient): """Tests valid email with plus sign.""" @@ -320,6 +346,7 @@ async def test_email_with_plus_sign(client: AsyncClient): response = await client.get("/test-email", cookies=cookies) assert response.status_code == status.HTTP_200_OK + @pytest.mark.anyio async def test_email_invalid_format_no_at(client: AsyncClient): """Tests invalid email without @ symbol.""" @@ -327,6 +354,7 @@ async def test_email_invalid_format_no_at(client: AsyncClient): response = await client.get("/test-email", cookies=cookies) assert response.status_code == status.HTTP_400_BAD_REQUEST + @pytest.mark.anyio async def test_email_invalid_format_no_domain(client: AsyncClient): """Tests invalid email without domain.""" @@ -334,6 +362,7 @@ async def test_email_invalid_format_no_domain(client: AsyncClient): response = await client.get("/test-email", cookies=cookies) assert response.status_code == status.HTTP_400_BAD_REQUEST + # NUMERIC RANGE TESTS @pytest.mark.anyio async def test_numeric_range_valid_min(client: AsyncClient): @@ -343,6 +372,7 @@ async def test_numeric_range_valid_min(client: AsyncClient): assert response.status_code == status.HTTP_200_OK assert response.json() == {"score": 0.0} + @pytest.mark.anyio async def test_numeric_range_valid_max(client: AsyncClient): """Tests numeric value at maximum boundary (le=100).""" @@ -351,6 +381,7 @@ async def test_numeric_range_valid_max(client: AsyncClient): assert response.status_code == status.HTTP_200_OK assert response.json() == {"score": 100.0} + @pytest.mark.anyio async def test_numeric_range_valid_middle(client: AsyncClient): """Tests numeric value in middle of range.""" @@ -359,6 +390,7 @@ async def test_numeric_range_valid_middle(client: AsyncClient): assert response.status_code == status.HTTP_200_OK assert response.json() == {"score": 50.0} + @pytest.mark.anyio async def test_numeric_range_below_min(client: AsyncClient): """Tests numeric value below minimum (< 0).""" @@ -367,6 +399,7 @@ async def test_numeric_range_below_min(client: AsyncClient): assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.json() == {"detail": "Score must be between 0 and 100."} + @pytest.mark.anyio async def test_numeric_range_above_max(client: AsyncClient): """Tests numeric value above maximum (> 100).""" @@ -375,6 +408,7 @@ async def test_numeric_range_above_max(client: AsyncClient): assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.json() == {"detail": "Score must be between 0 and 100."} + @pytest.mark.anyio async def test_numeric_range_float_valid(client: AsyncClient): """Tests decimal numeric value within range.""" @@ -383,6 +417,7 @@ async def test_numeric_range_float_valid(client: AsyncClient): assert response.status_code == status.HTTP_200_OK assert response.json() == {"score": 75.5} + @pytest.mark.anyio async def test_numeric_range_non_numeric(client: AsyncClient): """Tests non-numeric value.""" @@ -391,6 +426,7 @@ async def test_numeric_range_non_numeric(client: AsyncClient): assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.json() == {"detail": "Score must be a number."} + # ASYNC VALIDATOR TESTS @pytest.mark.anyio async def test_async_validator_valid(client: AsyncClient): @@ -400,14 +436,16 @@ async def test_async_validator_valid(client: AsyncClient): assert response.status_code == status.HTTP_200_OK assert response.json() == {"token": "valid_token123"} + @pytest.mark.anyio async def test_async_validator_invalid(client: AsyncClient): """Tests async validator function failure.""" cookies = {"async-token": "invalid_token123"} response = await client.get("/test-async-custom", cookies=cookies) - assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.status_code == 400 assert "Token must start with 'valid_'." in response.json()["detail"] + # EDGE CASE TESTS @pytest.mark.anyio async def test_cookie_with_special_characters(client: AsyncClient): @@ -416,6 +454,7 @@ async def test_cookie_with_special_characters(client: AsyncClient): response = await client.get("/test-length", cookies=cookies) assert response.status_code == status.HTTP_200_OK + @pytest.mark.anyio async def test_cookie_with_spaces(client: AsyncClient): """Tests cookie value containing spaces.""" @@ -425,6 +464,7 @@ async def test_cookie_with_spaces(client: AsyncClient): assert response.status_code == status.HTTP_200_OK assert response.json() == {"code": "a b c"} + @pytest.mark.anyio async def test_cookie_with_unicode_characters(client: AsyncClient): """Tests cookie value containing numeric and special characters.""" diff --git a/tests/test_csv_validator.py b/tests/test_csv_validator.py index c0cd279..c8d6e9b 100644 --- a/tests/test_csv_validator.py +++ b/tests/test_csv_validator.py @@ -12,9 +12,6 @@ import pytest from fastapi import UploadFile, HTTPException -# --- Module under test --- -# (Adjust this import path based on your project structure) -# We assume the code is in: fastapi_assets/validators/csv_validator.py from fastapi_assets.validators.csv_validator import CSVValidator # Mock pandas for the dependency test @@ -25,7 +22,7 @@ pd = None -# --- Fixtures --- +# Fixtures @pytest.fixture @@ -76,20 +73,20 @@ def _create_file( # Yield the factory function to the tests yield _create_file - # --- Teardown --- + # Teardown # Close all files created by the factory for f in files_to_close: f.close() -# --- Test Cases --- +# Test Cases @pytest.mark.asyncio class TestCSVValidator: """Groups all tests for the CSVValidator.""" - # --- Basic Success and File Handling --- + # Basic Success and File Handling async def test_happy_path_validation(self, mock_upload_file_factory: Callable[..., UploadFile]): """ @@ -128,7 +125,7 @@ async def test_file_is_rewound_after_validation( # Check if the file pointer is at the beginning assert await file.read() == csv_content.encode("utf-8") - # --- Dependency Check --- + # Dependency Check def test_pandas_dependency_check(self, monkeypatch): """ @@ -152,7 +149,7 @@ def test_pandas_dependency_check(self, monkeypatch): # Restore pandas for other tests monkeypatch.setattr("fastapi_assets.validators.csv_validator.pd", pd) - # --- CSV-Specific Validations --- + # CSV-Specific Validations @pytest.mark.asyncio @pytest.mark.parametrize( @@ -259,7 +256,7 @@ async def test_row_count_validation(self, mock_upload_file_factory, header_check validator_pass = CSVValidator(min_rows=3, max_rows=3, header_check_only=header_check_only) await validator_pass(mock_upload_file_factory(csv_content)) - # --- Error Handling and Custom Messages --- + # Error Handling and Custom Messages @pytest.mark.asyncio async def test_csv_parsing_error(self, mock_upload_file_factory): @@ -299,7 +296,7 @@ async def test_custom_error_messages(self, mock_upload_file_factory): await validator_row(mock_upload_file_factory(csv_content)) assert exc_row.value.detail == "File must have at least 5 data rows." - # --- Inherited Validation --- + # Inherited Validation @pytest.mark.asyncio async def test_inherited_max_size_validation(self, mock_upload_file_factory): diff --git a/tests/test_file_validator.py b/tests/test_file_validator.py index fb9b39c..3cb50a0 100644 --- a/tests/test_file_validator.py +++ b/tests/test_file_validator.py @@ -37,7 +37,7 @@ def mock_upload_file() -> MagicMock: return file -# --- Test Cases --- +# Test Cases @pytest.mark.asyncio @@ -122,7 +122,7 @@ async def test_call_invalid_content_type(self, mock_upload_file: MagicMock): with pytest.raises(HTTPException) as exc_info: await validator(mock_upload_file) - print(exc_info) + assert exc_info.value.status_code == 415 assert "unsupported media type" in exc_info.value.detail diff --git a/tests/test_header_validator.py b/tests/test_header_validator.py index 1f0fd29..e785b4d 100644 --- a/tests/test_header_validator.py +++ b/tests/test_header_validator.py @@ -4,11 +4,11 @@ import pytest from fastapi import HTTPException -from fastapi_assets.core.base_validator import ValidationError -from fastapi_assets.request_validators.header_validator import HeaderValidator +from fastapi_assets.core import ValidationError +from fastapi_assets.request_validators import HeaderValidator -# --- Fixtures --- +# Fixtures @pytest.fixture @@ -20,7 +20,7 @@ def base_validator(): @pytest.fixture def required_validator(): """Returns a HeaderValidator with required=True.""" - return HeaderValidator(required=True) + return HeaderValidator(default="Hello") @pytest.fixture @@ -45,13 +45,14 @@ def allowed_values_validator(): def custom_validator_obj(): """Returns a HeaderValidator with custom validator function.""" - def is_even_length(val: str) -> bool: - return len(val) % 2 == 0 + def is_even_length(val: str): + if len(val) % 2 != 0: + raise ValidationError(detail="Length is not even") - return HeaderValidator(validator=is_even_length) + return HeaderValidator(validators=[is_even_length]) -# --- Test Classes --- +# Test Classes class TestHeaderValidatorInit: @@ -61,44 +62,15 @@ def test_init_defaults(self): """Tests that all validation rules are None by default.""" validator = HeaderValidator() assert validator._allowed_values is None - assert validator._pattern is None - assert validator._custom_validator is None - assert validator._format_name is None - - def test_init_required_true(self): - """Tests that required flag is stored correctly.""" - validator = HeaderValidator(required=True) - assert validator._required is True - - def test_init_required_false(self): - """Tests that required can be set to False.""" - validator = HeaderValidator(required=False, default="default_value") - assert validator._required is False + assert validator._pattern_str is None + assert validator._custom_validators == [] def test_init_pattern_compilation(self): """Tests that pattern is compiled to regex.""" pattern = r"^[A-Z0-9]+$" validator = HeaderValidator(pattern=pattern) - assert validator._pattern is not None - assert validator._pattern.pattern == pattern - - def test_init_format_uuid4(self): - """Tests that format='uuid4' is recognized.""" - validator = HeaderValidator(format="uuid4") - assert validator._format_name == "uuid4" - assert validator._pattern is not None - - def test_init_format_email(self): - """Tests that format='email' is recognized.""" - validator = HeaderValidator(format="email") - assert validator._format_name == "email" - assert validator._pattern is not None - - def test_init_format_bearer_token(self): - """Tests that format='bearer_token' is recognized.""" - validator = HeaderValidator(format="bearer_token") - assert validator._format_name == "bearer_token" - assert validator._pattern is not None + assert validator._pattern_str is not None + assert validator._pattern_str == pattern def test_init_invalid_format(self): """Tests that invalid format raises ValueError.""" @@ -119,13 +91,13 @@ def test_init_allowed_values(self): def test_init_custom_validator_function(self): """Tests that custom validator function is stored.""" - def is_positive(val: str) -> bool: - return val.startswith("+") + def is_positive(val: str): + if not val.startswith("+"): + raise ValidationError(detail="Value does not start with '+'") - validator = HeaderValidator(validator=is_positive) - assert validator._custom_validator is not None - assert validator._custom_validator("+test") is True - assert validator._custom_validator("-test") is False + validator = HeaderValidator(validators=[is_positive]) + assert validator._custom_validators is not None + assert validator._custom_validators[0]("+test") is None def test_init_custom_error_detail(self): """Tests that custom error detail is stored.""" @@ -149,27 +121,6 @@ def test_required_with_value(self, required_validator): except ValidationError: pytest.fail("Required validation failed with valid value") - def test_required_missing_value(self, required_validator): - """Tests required validation fails when value is None.""" - with pytest.raises(ValidationError) as e: - required_validator._validate_required(None) - - assert e.value.status_code == 400 - assert "missing" in e.value.detail.lower() - - def test_required_empty_string(self, required_validator): - """Tests required validation fails with empty string.""" - with pytest.raises(ValidationError): - required_validator._validate_required("") - - def test_not_required_with_none(self, base_validator): - """Tests validation passes when not required and value is None.""" - base_validator._required = False - try: - base_validator._validate_required(None) - except ValidationError: - pytest.fail("Non-required validation should pass with None") - class TestHeaderValidatorValidateAllowedValues: """Tests for the _validate_allowed_values method.""" @@ -233,7 +184,7 @@ def test_pattern_invalid_match(self, pattern_validator): pattern_validator._validate_pattern("short") assert e.value.status_code == 400 - assert "does not match" in e.value.detail.lower() + assert "invalid format" in e.value.detail.lower() def test_pattern_format_uuid4_valid(self): """Tests uuid4 format validation passes.""" @@ -282,95 +233,109 @@ def test_pattern_format_email_invalid(self): class TestHeaderValidatorValidateCustom: """Tests for the _validate_custom method.""" - def test_custom_no_validator(self, base_validator): + @pytest.mark.asyncio + async def test_custom_no_validator(self, base_validator): """Tests validation passes with no custom validator.""" try: - base_validator._validate_custom("any_value") + await base_validator._validate_custom("any_value") except ValidationError: pytest.fail("Validation failed with no custom validator") - def test_custom_validator_valid(self, custom_validator_obj): + @pytest.mark.asyncio + async def test_custom_validator_valid(self, custom_validator_obj): """Tests custom validator passes on valid input.""" try: - custom_validator_obj._validate_custom("even") # 4 chars + await custom_validator_obj._validate_custom("even") # 4 chars except ValidationError: pytest.fail("Valid custom validation failed") - def test_custom_validator_invalid(self, custom_validator_obj): + @pytest.mark.asyncio + async def test_custom_validator_invalid(self, custom_validator_obj): """Tests custom validator fails on invalid input.""" with pytest.raises(ValidationError) as e: - custom_validator_obj._validate_custom("odd") # 3 chars + await custom_validator_obj._validate_custom("odd") # 3 chars assert e.value.status_code == 400 # Accept either failure message depending on your validator code + # The custom validator raises ValidationError with detail="Length is not even" assert ( - "custom validation failed" in e.value.detail.lower() + "failed custom validation" in e.value.detail.lower() or "custom validation error" in e.value.detail.lower() + or "length is not even" in e.value.detail.lower() ) - def test_custom_validator_exception(self): + @pytest.mark.asyncio + async def test_custom_validator_exception(self): """Tests custom validator exception is caught.""" - def buggy_validator(val: str) -> bool: + def buggy_validator(val: str): raise ValueError("Unexpected error") - validator = HeaderValidator(validator=buggy_validator) + validator = HeaderValidator(validators=[buggy_validator]) with pytest.raises(ValidationError) as e: - validator._validate_custom("test") + await validator._validate_custom("test") - assert "custom validation error" in e.value.detail.lower() + assert "custom validation failed" in e.value.detail.lower() class TestHeaderValidatorValidate: """Tests for the main _validate method.""" - def test_validate_valid_header(self): + @pytest.mark.asyncio + async def test_validate_valid_header(self): """Tests full validation pipeline with valid header.""" validator = HeaderValidator( required=True, allowed_values=["api", "web"], pattern=r"^[a-z]+$" ) try: - result = validator._validate("api") + result = await validator._validate("api") assert result == "api" except ValidationError: pytest.fail("Valid header failed validation") - def test_validate_fails_required(self): + @pytest.mark.asyncio + async def test_validate_fails_required(self): """Tests validation fails on required check.""" validator = HeaderValidator(required=True) - with pytest.raises(HTTPException): - validator._validate(None) + with pytest.raises(ValidationError): + await validator._validate(None) - def test_validate_fails_allowed_values(self): + @pytest.mark.asyncio + async def test_validate_fails_allowed_values(self): """Tests validation fails on allowed values check.""" validator = HeaderValidator(allowed_values=["good"]) - with pytest.raises(HTTPException): - validator._validate("bad") + with pytest.raises(ValidationError): + await validator._validate("bad") - def test_validate_fails_pattern(self): + @pytest.mark.asyncio + async def test_validate_fails_pattern(self): """Tests validation fails on pattern check.""" validator = HeaderValidator(pattern=r"^[0-9]+$") - with pytest.raises(HTTPException): - validator._validate("abc") + with pytest.raises(ValidationError): + await validator._validate("abc") - def test_validate_fails_custom(self): + @pytest.mark.asyncio + async def test_validate_fails_custom(self): """Tests validation fails on custom validator.""" - def no_spaces(val: str) -> bool: - return " " not in val + def no_spaces(val: str): + if " " in val: + raise ValidationError(detail="Spaces are not allowed") - validator = HeaderValidator(validator=no_spaces) - with pytest.raises(HTTPException): - validator._validate("has space") + validator = HeaderValidator(validators=[no_spaces]) + with pytest.raises(ValidationError): + await validator._validate("has space") - def test_validate_empty_optional_header(self): + @pytest.mark.asyncio + async def test_validate_empty_optional_header(self): """Tests optional header with empty string passes.""" - validator = HeaderValidator(required=False) - result = validator._validate("") + validator = HeaderValidator(default="") + result = await validator._validate("") assert result == "" - def test_validate_none_optional_header(self): + @pytest.mark.asyncio + async def test_validate_none_optional_header(self): """Tests optional header with None passes.""" - validator = HeaderValidator(required=False) - result = validator._validate(None) - assert result is None or result == "" + validator = HeaderValidator(default=None) + result = await validator._validate(None) + assert result is None diff --git a/tests/test_image_validator.py b/tests/test_image_validator.py index 904c044..4967cc4 100644 --- a/tests/test_image_validator.py +++ b/tests/test_image_validator.py @@ -22,7 +22,7 @@ class MockValidationError(Exception): """Mock a ValidationError for testing.""" - def __init__(self, detail: str, status_code: int): + def __init__(self, detail: str, status_code: int = 400): self.detail = detail self.status_code = status_code super().__init__(detail) @@ -129,6 +129,7 @@ def create_mock_image_file( file.filename = filename file.content_type = content_type file.file = buffer + file.size = len(buffer.getvalue()) # Set the size attribute # Create a wrapper for seek async def mock_seek(offset): @@ -161,6 +162,7 @@ def create_mock_text_file(filename: str) -> UploadFile: file.filename = filename file.content_type = "text/plain" file.file = buffer + file.size = len(buffer.getvalue()) # Set the size attribute # Create a wrapper for seek async def mock_seek(offset): @@ -281,7 +283,7 @@ async def test_inherited_max_size_failure(self): await validator(file) assert exc_info.value.status_code == 413 # From our mock - assert "File is too large" in exc_info.value.detail + assert "exceeds the maximum limit" in exc_info.value.detail finally: await file.close() diff --git a/tests/test_path_validator.py b/tests/test_path_validator.py index 986e4e1..af808fd 100644 --- a/tests/test_path_validator.py +++ b/tests/test_path_validator.py @@ -1,730 +1,273 @@ """ -tests for the PathValidator class. +Test suite for the PathValidator class. """ -from fastapi import HTTPException + import pytest -from fastapi_assets.core.base_validator import ValidationError -from fastapi_assets.request_validators.path_validator import PathValidator - -# Fixtures for common PathValidator configurations -@pytest.fixture -def base_validator(): - """Returns a basic PathValidator with no rules.""" - return PathValidator() - -@pytest.fixture -def numeric_validator(): - """Returns a PathValidator configured for numeric validation.""" - return PathValidator(gt=0, lt=1000) - -@pytest.fixture -def string_validator(): - """Returns a PathValidator configured for string validation.""" - return PathValidator( +import asyncio +from fastapi import FastAPI, Depends, HTTPException +from fastapi.testclient import TestClient +from typing import Any, Callable, List + + +class MockValidationError(Exception): + """Minimal mock of the custom ValidationError.""" + + def __init__(self, detail: str, status_code: int = 400): + self.detail = detail + self.status_code = status_code + super().__init__(detail) + + +class MockBaseValidator: + """ + Minimal mock of the BaseValidator to provide + the methods PathValidator inherits. + """ + + def __init__( + self, status_code: int, error_detail: str, validators: List[Callable] | None = None + ): + self._status_code = status_code + self._error_detail = error_detail + self._custom_validators = validators or [] + + async def _validate_custom(self, value: Any) -> None: + """Mock implementation of custom validator runner.""" + for validator in self._custom_validators: + try: + if asyncio.iscoroutinefunction(validator): + await validator(value) + else: + validator(value) + except Exception as e: + # Raise the specific error PathValidator expects + raise MockValidationError(detail=str(e), status_code=400) from e + + def _raise_error(self, value: Any, status_code: int, detail: str) -> None: + """Mock implementation of the error raiser.""" + raise HTTPException(status_code=status_code, detail=detail) + + +# Patch the imports in the module to be tested +# This is a professional testing pattern to inject mocks +import sys +import unittest.mock + +# Create mock modules +mock_core_module = unittest.mock.MagicMock() +mock_core_module.BaseValidator = MockBaseValidator +mock_core_module.ValidationError = MockValidationError + +# Add the mock module to sys.modules +# This ensures that when 'path_validator' imports from 'fastapi_assets.core', +# it gets our mock classes. +sys.modules["fastapi_assets.core"] = mock_core_module + +# Now we can safely import the class to be tested +from fastapi_assets.request_validators import PathValidator + +# +# Test Cases +# + + +def test_standard_path_validation_numeric(): + """ + Tests that standard validations (gt, lt) from fastapi.Path + are correctly applied and that type coercion works. + """ + app = FastAPI() + item_id_validator = PathValidator("item_id", _type=int, gt=0, lt=10) + + @app.get("/items/{item_id}") + def get_item(item_id: int = Depends(item_id_validator())): + # We also check the type to ensure coercion from string happened + return {"item_id": item_id, "type": str(type(item_id))} + + client = TestClient(app) + + # 1. Success case + response = client.get("/items/5") + assert response.status_code == 200 + assert response.json() == {"item_id": 5, "type": ""} + + # 2. Failure case (gt) + response = client.get("/items/0") + assert response.status_code == 422 # Pydantic validation error + assert "greater than 0" in response.text + + # 3. Failure case (lt) + response = client.get("/items/10") + assert response.status_code == 422 + assert "less than 10" in response.text + + # 4. Failure case (type coercion) + response = client.get("/items/abc") + assert response.status_code == 422 + assert "Input should be a valid integer" in response.text + + +def test_standard_path_validation_string(): + """ + Tests that standard string validations (min_length, max_length, pattern) + from fastapi.Path are correctly applied. + """ + app = FastAPI() + username_validator = PathValidator( + "username", + _type=str, min_length=3, - max_length=15, - pattern=r"^[a-zA-Z0-9_]+$" + max_length=5, + pattern=r"^[a-z]+$", # only lowercase letters ) -@pytest.fixture -def allowed_values_validator(): - """Returns a PathValidator with allowed values.""" - return PathValidator( - allowed_values=["active", "inactive", "pending"] + @app.get("/users/{username}") + def get_user(username: str = Depends(username_validator())): + return {"username": username} + + client = TestClient(app) + + # 1. Success case + response = client.get("/users/abc") + assert response.status_code == 200 + assert response.json() == {"username": "abc"} + + # 2. Failure case (min_length) + response = client.get("/users/ab") + assert response.status_code == 422 + assert "at least 3 characters" in response.text + + # 3. Failure case (max_length) + response = client.get("/users/abcdef") + assert response.status_code == 422 + assert "at most 5 characters" in response.text + + # 4. Failure case (pattern) + response = client.get("/users/123") + assert response.status_code == 422 + assert "String should match pattern" in response.text + + +def test_custom_validation_allowed_values(): + """ + Tests the custom 'allowed_values' feature of PathValidator. + """ + app = FastAPI() + mode_validator = PathValidator("mode", _type=str, allowed_values=["read", "write"]) + + @app.get("/modes/{mode}") + def get_mode(mode: str = Depends(mode_validator())): + return {"mode": mode} + + client = TestClient(app) + + # 1. Success cases + response_read = client.get("/modes/read") + assert response_read.status_code == 200 + assert response_read.json() == {"mode": "read"} + + response_write = client.get("/modes/write") + assert response_write.status_code == 200 + assert response_write.json() == {"mode": "write"} + + # 2. Failure case + response = client.get("/modes/admin") + # This fails our custom check, which raises an HTTPException + # based on the (mocked) _raise_error method. + assert response.status_code == 400 + assert "Value 'admin' is not allowed" in response.text + assert "Allowed values are: read, write" in response.text + + +def test_custom_validation_validators_list(): + """ + Tests the custom 'validators' list with both sync and async functions. + """ + + # Custom validator functions for this test + def must_be_even(value: int): + """Sync validator.""" + if value % 2 != 0: + raise ValueError("Value must be even") + + async def must_be_multiple_of_three(value: int): + """Async validator.""" + await asyncio.sleep(0) # Simulate async work + if value % 3 != 0: + raise Exception("Value must be a multiple of three") + + # - + + app = FastAPI() + custom_num_validator = PathValidator( + "num", _type=int, validators=[must_be_even, must_be_multiple_of_three] ) -# Test class for constructor __init__ behavior -class TestPathValidatorInit: - def test_init_defaults(self): - """Tests that all validation rules are None by default.""" - validator = PathValidator() - assert validator._allowed_values is None - assert validator._pattern is None - assert validator._min_length is None - assert validator._max_length is None - assert validator._gt is None - assert validator._lt is None - assert validator._ge is None - assert validator._le is None - assert validator._custom_validator is None - - def test_init_allowed_values(self): - """Tests that allowed_values are stored correctly.""" - values = ["active", "inactive"] - validator = PathValidator(allowed_values=values) - assert validator._allowed_values == values - - def test_init_pattern_compilation(self): - """Tests that regex pattern is compiled.""" - pattern = r"^[a-z0-9]+$" - validator = PathValidator(pattern=pattern) - assert validator._pattern is not None - assert validator._pattern.pattern == pattern - - def test_init_numeric_bounds(self): - """Tests that numeric bounds are stored correctly.""" - validator = PathValidator(gt=0, lt=100, ge=1, le=99) - assert validator._gt == 0 - assert validator._lt == 100 - assert validator._ge == 1 - assert validator._le == 99 - - def test_init_length_bounds(self): - """Tests that length bounds are stored correctly.""" - validator = PathValidator(min_length=5, max_length=20) - assert validator._min_length == 5 - assert validator._max_length == 20 - - def test_init_custom_error_detail(self): - """Tests that custom error messages are stored.""" - custom_error = "Invalid path parameter" - validator = PathValidator(error_detail=custom_error) - print(validator._error_detail) - - # _error_detail attribute holds error message - assert validator._error_detail == custom_error or custom_error in str(validator.__dict__) - - def test_init_custom_validator_function(self): - """Tests that custom validator function is stored.""" - def is_even(x): return x % 2 == 0 - validator = PathValidator(validator=is_even) - # Validate custom function works - assert validator._custom_validator(4) is True - assert validator._custom_validator(3) is False - - def test_init_fastapi_path_creation(self): - """Tests that internal FastAPI Path object is created.""" - validator = PathValidator( - title="Item ID", - description="The unique identifier", - gt=0, - lt=1000 - ) - assert validator._path_param is not None - - def test_init_combined_rules(self): - """Tests initialization with multiple combined rules.""" - validator = PathValidator( - min_length=3, - max_length=20, - pattern=r"^[a-zA-Z]+$", - title="Category", - description="Product category slug" - ) - assert validator._min_length == 3 - assert validator._max_length == 20 - assert validator._pattern is not None - -# Validation method tests -class TestPathValidatorValidateAllowedValues: - def test_allowed_values_no_rule(self, base_validator): - """Validation should pass if no rule is set.""" - try: - base_validator._validate_allowed_values("any_value") - except ValidationError: - pytest.fail("Validation failed when no rule was set.") - - def test_allowed_values_valid(self, allowed_values_validator): - """Test valid allowed value.""" - try: - allowed_values_validator._validate_allowed_values("active") - except ValidationError: - pytest.fail("Failed on valid allowed value.") - - def test_allowed_values_invalid(self, allowed_values_validator): - """Test invalid allowed value raises ValidationError.""" - with pytest.raises(ValidationError): - allowed_values_validator._validate_allowed_values("deleted") - -class TestPathValidatorValidatePattern: - def test_pattern_no_rule(self, base_validator): - """Validation passes when no pattern rule.""" - try: - base_validator._validate_pattern("anything@123!@#") - except ValidationError: - pytest.fail("Validation failed when no pattern rule.") - - def test_pattern_valid_match(self, string_validator): - """Valid pattern match.""" - try: - string_validator._validate_pattern("user_123") - except ValidationError: - pytest.fail("Validation failed on valid pattern.") - - def test_pattern_invalid_match(self, string_validator): - """Invalid pattern raises ValidationError.""" - with pytest.raises(ValidationError): - string_validator._validate_pattern("user@123") - - def test_pattern_non_string_ignored(self, string_validator): - """Skip pattern validation for non-strings.""" - try: - string_validator._validate_pattern(123) - except ValidationError: - pytest.fail("Pattern validation should not apply to non-strings.") - - def test_pattern_email_like(self): - """Email pattern with valid and invalid cases.""" - validator = PathValidator(pattern=r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$") - try: - validator._validate_pattern("user.name+tag@example.com") - except ValidationError: - pytest.fail("Valid email-like pattern failed") - with pytest.raises(ValidationError): - validator._validate_pattern("user@domain") # missing TLD - -# Length validation tests -class TestPathValidatorValidateLength: - def test_length_no_rule(self, base_validator): - """Validation passes when no length rule.""" - try: - base_validator._validate_length("x") - base_validator._validate_length("longer") - except ValidationError: - pytest.fail("Failed no length rule.") - - def test_length_valid_within_bounds(self, string_validator): - """Valid length within bounds.""" - try: - string_validator._validate_length("hello") - except ValidationError: - pytest.fail("Failed valid length.") - - def test_length_too_short(self, string_validator): - """Fails if shorter than min_length.""" - with pytest.raises(ValidationError): - string_validator._validate_length("ab") - - def test_length_too_long(self, string_validator): - """Fails if longer than max_length.""" - with pytest.raises(ValidationError): - string_validator._validate_length("a"*20) - -# Numeric bounds validation -class TestPathValidatorValidateNumericBounds: - def test_no_rule(self, base_validator): - try: - base_validator._validate_numeric_bounds(999) - base_validator._validate_numeric_bounds(-999) - except ValidationError: - pytest.fail("Failed no numeric rule.") - - def test_gt_lt(self, numeric_validator): - try: - numeric_validator._validate_numeric_bounds(1) - numeric_validator._validate_numeric_bounds(999) - except ValidationError: - pytest.fail("Failed valid bounds.") - with pytest.raises(ValidationError): - numeric_validator._validate_numeric_bounds(0) - - def test_ge_le(self): - validator = PathValidator(ge=0, le=10) - try: - validator._validate_numeric_bounds(0) - validator._validate_numeric_bounds(10) - except ValidationError: - pytest.fail("Failed boundary values.") - with pytest.raises(ValidationError): - validator._validate_numeric_bounds(-1) - -# Custom validation tests -class TestPathValidatorValidateCustom: - def test_no_custom_validator(self, base_validator): - try: - base_validator._validate_custom("test") - except ValidationError: - pytest.fail("Failed with no custom validator.") - def test_valid_custom(self): - def is_even(x): return x % 2 == 0 - v = PathValidator(validator=is_even) - try: - v._validate_custom(4) - except ValidationError: - pytest.fail("Valid custom validation failed.") - def test_invalid_custom(self): - def is_even(x): return x % 2 == 0 - v = PathValidator(validator=is_even) - with pytest.raises(ValidationError): - v._validate_custom(3) - -# Integration of multiple validations -class TestPathValidatorIntegration: - def test_combined_valid(self): - v = PathValidator(allowed_values=["ok"], pattern=r"^ok$", min_length=2, max_length=2) - try: - v._validate("ok") - except ValidationError: - pytest.fail("Valid data failed validation.") - - def test_fail_in_combined(self): - v = PathValidator(allowed_values=["ok"], pattern=r"^ok$", min_length=2, max_length=2) - with pytest.raises(HTTPException): - v._validate("no") - - -# Edge case tests for bounds -class TestPathValidatorNumericEdgeCases: - """Test edge cases and boundary conditions for numeric validation.""" - - def test_gt_with_equal_value(self): - """Value equal to gt boundary should fail.""" - validator = PathValidator(gt=10) - with pytest.raises(ValidationError) as exc_info: - validator._validate_numeric_bounds(10) - assert "greater than 10" in str(exc_info.value.detail) - - def test_lt_with_equal_value(self): - """Value equal to lt boundary should fail.""" - validator = PathValidator(lt=10) - with pytest.raises(ValidationError) as exc_info: - validator._validate_numeric_bounds(10) - assert "less than 10" in str(exc_info.value.detail) - - def test_ge_with_equal_value(self): - """Value equal to ge boundary should pass.""" - validator = PathValidator(ge=10) - try: - validator._validate_numeric_bounds(10) - except ValidationError: - pytest.fail("GE with equal value should pass") - - def test_le_with_equal_value(self): - """Value equal to le boundary should pass.""" - validator = PathValidator(le=10) - try: - validator._validate_numeric_bounds(10) - except ValidationError: - pytest.fail("LE with equal value should pass") - - def test_negative_numeric_bounds(self): - """Test numeric bounds with negative values.""" - validator = PathValidator(gt=-100, lt=-10) - try: - validator._validate_numeric_bounds(-50) - except ValidationError: - pytest.fail("Valid negative value failed") - with pytest.raises(ValidationError): - validator._validate_numeric_bounds(-100) - - def test_float_numeric_bounds(self): - """Test numeric bounds with float values.""" - validator = PathValidator(gt=0.0, lt=1.0) - try: - validator._validate_numeric_bounds(0.5) - except ValidationError: - pytest.fail("Valid float value failed") - with pytest.raises(ValidationError): - validator._validate_numeric_bounds(1.0) - - def test_zero_as_boundary(self): - """Test with zero as boundary value.""" - validator = PathValidator(ge=0, le=0) - try: - validator._validate_numeric_bounds(0) - except ValidationError: - pytest.fail("Zero should be valid with ge=0, le=0") - with pytest.raises(ValidationError): - validator._validate_numeric_bounds(1) - - -# Edge case tests for string length -class TestPathValidatorStringEdgeCases: - """Test edge cases and boundary conditions for string validation.""" - - def test_empty_string_with_min_length(self): - """Empty string should fail if min_length is set.""" - validator = PathValidator(min_length=1) - with pytest.raises(ValidationError) as exc_info: - validator._validate_length("") - assert "too short" in str(exc_info.value.detail) - - def test_min_length_exact(self): - """String exactly at min_length should pass.""" - validator = PathValidator(min_length=5) - try: - validator._validate_length("exact") - except ValidationError: - pytest.fail("Exact min_length should pass") - - def test_max_length_exact(self): - """String exactly at max_length should pass.""" - validator = PathValidator(max_length=5) - try: - validator._validate_length("exact") - except ValidationError: - pytest.fail("Exact max_length should pass") - - def test_unicode_string_length(self): - """Test length validation with unicode characters.""" - validator = PathValidator(min_length=3, max_length=5) - try: - validator._validate_length("😀😁😂") # 3 emoji characters - except ValidationError: - pytest.fail("Valid unicode string failed") - - def test_zero_length_bounds(self): - """Test with min and max length of zero.""" - validator = PathValidator(min_length=0, max_length=0) - try: - validator._validate_length("") - except ValidationError: - pytest.fail("Empty string should be valid with min=0, max=0") - with pytest.raises(ValidationError): - validator._validate_length("x") - - -# Edge case tests for pattern matching -class TestPathValidatorPatternEdgeCases: - """Test edge cases for regex pattern validation.""" - - def test_pattern_with_special_characters(self): - """Pattern with special regex characters.""" - validator = PathValidator(pattern=r"^[\w\-\.]+@[\w\-\.]+\.\w+$") - try: - validator._validate_pattern("user-name.test@sub-domain.co.uk") - except ValidationError: - pytest.fail("Valid email-like pattern failed") - with pytest.raises(ValidationError): - validator._validate_pattern("invalid@domain") - - def test_pattern_case_sensitive(self): - """Regex patterns are case-sensitive by default.""" - validator = PathValidator(pattern=r"^[a-z]+$") - try: - validator._validate_pattern("lowercase") - except ValidationError: - pytest.fail("Lowercase letters should match [a-z]") - with pytest.raises(ValidationError): - validator._validate_pattern("UPPERCASE") - - def test_pattern_with_anchors(self): - """Pattern with start and end anchors.""" - validator = PathValidator(pattern=r"^START.*END$") - try: - validator._validate_pattern("START-middle-END") - except ValidationError: - pytest.fail("String with anchors should match") - with pytest.raises(ValidationError): - validator._validate_pattern("MIDDLE-START-END") - - def test_pattern_match_from_start(self): - """re.match() only matches from the start of string.""" - validator = PathValidator(pattern=r"test") - try: - validator._validate_pattern("test_string") - except ValidationError: - pytest.fail("Pattern should match from start") - # This should fail because re.match only checks beginning - with pytest.raises(ValidationError): - validator._validate_pattern("this_is_a_test_string") - - def test_pattern_with_groups(self): - """Pattern with capture groups.""" - validator = PathValidator(pattern=r"^(\d{4})-(\d{2})-(\d{2})$") - try: - validator._validate_pattern("2025-11-04") - except ValidationError: - pytest.fail("Valid date format should match") - with pytest.raises(ValidationError): - validator._validate_pattern("2025/11/04") - - -# Allowed values edge cases -class TestPathValidatorAllowedValuesEdgeCases: - """Test edge cases for allowed values validation.""" - - def test_allowed_values_with_none(self): - """Test when None is in allowed values.""" - validator = PathValidator(allowed_values=[None, "active", "inactive"]) - try: - validator._validate_allowed_values(None) - except ValidationError: - pytest.fail("None should be allowed if in list") - - def test_allowed_values_case_sensitive(self): - """Allowed values matching is case-sensitive.""" - validator = PathValidator(allowed_values=["Active", "Inactive"]) - try: - validator._validate_allowed_values("Active") - except ValidationError: - pytest.fail("Case-sensitive match should work") - with pytest.raises(ValidationError): - validator._validate_allowed_values("active") - - def test_allowed_values_numeric_types(self): - """Test allowed values with numeric types.""" - validator = PathValidator(allowed_values=[1, 2, 3]) - try: - validator._validate_allowed_values(2) - except ValidationError: - pytest.fail("Numeric allowed value should work") - with pytest.raises(ValidationError): - validator._validate_allowed_values("2") # String "2" != int 2 - - def test_allowed_values_empty_list(self): - """Empty allowed values list should reject everything.""" - validator = PathValidator(allowed_values=[]) - with pytest.raises(ValidationError): - validator._validate_allowed_values("anything") - - def test_allowed_values_with_duplicates(self): - """Allowed values list with duplicates.""" - validator = PathValidator(allowed_values=["status", "status", "active"]) - try: - validator._validate_allowed_values("status") - except ValidationError: - pytest.fail("Duplicates shouldn't affect validation") - - -# Custom validator edge cases -class TestPathValidatorCustomValidatorEdgeCases: - """Test edge cases for custom validator functions.""" - - def test_custom_validator_exception_handling(self): - """Custom validator that raises exception.""" - def bad_validator(x): - raise ValueError("Something went wrong") - - validator = PathValidator(validator=bad_validator) - with pytest.raises(ValidationError) as exc_info: - validator._validate_custom("test") - assert "Custom validation error" in str(exc_info.value.detail) - - def test_custom_validator_returns_false(self): - """Custom validator returns False.""" - def always_fail(x): - return False - - validator = PathValidator(validator=always_fail) - with pytest.raises(ValidationError) as exc_info: - validator._validate_custom("test") - assert "Custom validation failed" in str(exc_info.value.detail) - - def test_custom_validator_returns_true(self): - """Custom validator returns True.""" - def always_pass(x): - return True - - validator = PathValidator(validator=always_pass) - try: - validator._validate_custom("test") - except ValidationError: - pytest.fail("Custom validator returning True should pass") - - def test_custom_validator_with_complex_logic(self): - """Custom validator with complex validation logic.""" - def validate_phone(phone): - import re - return bool(re.match(r"^\+?1?\d{9,15}$", str(phone))) - - validator = PathValidator(validator=validate_phone) - try: - validator._validate_custom("+14155552671") - except ValidationError: - pytest.fail("Valid phone should pass") - with pytest.raises(ValidationError): - validator._validate_custom("123") - - def test_custom_validator_lambda(self): - """Custom validator using lambda function.""" - validator = PathValidator(validator=lambda x: len(str(x)) > 3) - try: - validator._validate_custom("test") - except ValidationError: - pytest.fail("Lambda validator should work") - with pytest.raises(ValidationError): - validator._validate_custom("ab") - - -# Complete validation flow tests -class TestPathValidatorCompleteFlow: - """Test complete validation flows with multiple rules.""" - - def test_all_validations_pass(self): - """All validation rules pass together.""" - validator = PathValidator( - allowed_values=["user_123", "admin_456"], - pattern=r"^[a-z]+_\d+$", - min_length=7, - max_length=10, - validator=lambda x: "_" in x - ) - try: - validator._validate("user_123") - except (ValidationError, HTTPException): - pytest.fail("All validations should pass") - - def test_fail_on_first_validation(self): - """Validation fails on first rule.""" - validator = PathValidator( - allowed_values=["valid"], - pattern=r"^[a-z]+$", - min_length=3 - ) - with pytest.raises(HTTPException): - validator._validate("invalid") - - def test_multiple_combined_rules(self): - """Complex scenario with multiple rules.""" - validator = PathValidator( - min_length=5, - max_length=15, - pattern=r"^[a-zA-Z0-9_-]+$", - allowed_values=["user_name", "admin_test", "guest-user"], - validator=lambda x: not x.startswith("_") - ) - for valid_value in ["user_name", "admin_test", "guest-user"]: - try: - validator._validate(valid_value) - except (ValidationError, HTTPException): - pytest.fail(f"'{valid_value}' should be valid") - - def test_validation_error_messages(self): - """Validation error messages are informative.""" - validator = PathValidator( - allowed_values=["a", "b", "c"], - min_length=2, - max_length=5 - ) - try: - validator._validate("d") - except HTTPException as e: - assert "not allowed" in str(e.detail).lower() or "validation" in str(e.detail).lower() - - -# Non-string and non-numeric type handling -class TestPathValidatorTypeHandling: - """Test handling of various data types.""" - - def test_non_string_skips_string_validations(self): - """Non-string types skip string-specific validations.""" - validator = PathValidator(min_length=3, max_length=10) - try: - validator._validate_length(123) - validator._validate_pattern(123) - except ValidationError: - pytest.fail("Non-strings should skip string validations") - - def test_non_numeric_skips_numeric_validations(self): - """Non-numeric types skip numeric-specific validations.""" - validator = PathValidator(gt=0, lt=100) - try: - validator._validate_numeric_bounds("test") - except ValidationError: - pytest.fail("Non-numeric should skip numeric validations") - - def test_boolean_type_validation(self): - """Test validation with boolean values.""" - validator = PathValidator(allowed_values=[True, False]) - try: - validator._validate_allowed_values(True) - validator._validate_allowed_values(False) - except ValidationError: - pytest.fail("Booleans should validate against allowed values") - - def test_list_type_validation(self): - """Test validation with list/collection types.""" - validator = PathValidator( - allowed_values=[[1, 2], [3, 4], [5, 6]], - validator=lambda x: isinstance(x, list) - ) - try: - validator._validate_allowed_values([1, 2]) - validator._validate_custom([3, 4]) - except ValidationError: - pytest.fail("Lists should validate correctly") - - -# Initialization parameter combinations -class TestPathValidatorInitParameterCombinations: - """Test various parameter combinations during initialization.""" - - def test_init_with_all_parameters(self): - """Initialize with all possible parameters.""" - validator = PathValidator( - default=..., - allowed_values=["a", "b"], - pattern=r"^[a-z]$", - min_length=1, - max_length=1, - gt=0, - lt=10, - ge=1, - le=9, - validator=lambda x: x in ["a", "b"], - title="Test Parameter", - description="A test path parameter", - alias="test_param", - deprecated=False, - error_detail="Test error", - status_code=422 - ) - assert validator._allowed_values == ["a", "b"] - assert validator._pattern is not None - assert validator._min_length == 1 - assert validator._max_length == 1 - - def test_init_only_required(self): - """Initialize with only required parameters.""" - validator = PathValidator() - assert validator._allowed_values is None - assert validator._pattern is None - assert validator._min_length is None - assert validator._max_length is None - - def test_init_with_only_custom_validator(self): - """Initialize with only custom validator.""" - custom = lambda x: x > 0 - validator = PathValidator(validator=custom) - assert validator._custom_validator is custom - assert validator._allowed_values is None - - def test_status_code_default(self): - """Default status code should be 400.""" - validator = PathValidator() - # Status code is set in parent class - - -# Error message verification tests -class TestPathValidatorErrorMessages: - """Test that error messages are clear and informative.""" - - def test_allowed_values_error_message(self): - """Error message includes list of allowed values.""" - validator = PathValidator(allowed_values=["a", "b", "c"]) - try: - validator._validate_allowed_values("d") - except ValidationError as e: - assert "a" in str(e.detail) - assert "b" in str(e.detail) - assert "c" in str(e.detail) - - def test_pattern_error_message_includes_pattern(self): - """Error message includes the regex pattern.""" - pattern = r"^[0-9]{3}$" - validator = PathValidator(pattern=pattern) - try: - validator._validate_pattern("abc") - except ValidationError as e: - assert pattern in str(e.detail) - - def test_length_error_message_info(self): - """Length error includes bounds information.""" - validator = PathValidator(min_length=5, max_length=10) - try: - validator._validate_length("ab") - except ValidationError as e: - assert "5" in str(e.detail) - try: - validator._validate_length("a" * 15) - except ValidationError as e: - assert "10" in str(e.detail) - - def test_numeric_bounds_error_messages(self): - """Numeric bounds errors include boundary values.""" - validator = PathValidator(gt=100) - try: - validator._validate_numeric_bounds(50) - except ValidationError as e: - assert "100" in str(e.detail) + @app.get("/nums/{num}") + def get_num(num: int = Depends(custom_num_validator())): + return {"num": num} + + client = TestClient(app) + + # 1. Success case (passes both validators) + response = client.get("/nums/6") + assert response.status_code == 200 + assert response.json() == {"num": 6} + + # 2. Failure case (fails sync validator) + response = client.get("/nums/9") + assert response.status_code == 400 + assert "Value must be even" in response.text + + # 3. Failure case (fails async validator) + response = client.get("/nums/4") + assert response.status_code == 400 + assert "Value must be a multiple of three" in response.text + + +def test_validator_isolation(): + """ + Tests that multiple PathValidator instances on the same app + do not interfere with each other's signatures. This is the + most critical test given the history of bugs. + """ + app = FastAPI() + + # 1. Define two different validators + item_id_validator = PathValidator("item_id", _type=int, gt=10) + username_validator = PathValidator("username", _type=str, min_length=5) + + # 2. Define two separate endpoints + @app.get("/items/{item_id}") + def get_item(item_id: int = Depends(item_id_validator())): + return {"item_id": item_id} + + @app.get("/users/{username}") + def get_user(username: str = Depends(username_validator())): + return {"username": username} + + client = TestClient(app) + + # 3. Test both endpoints successfully + response_item = client.get("/items/11") + assert response_item.status_code == 200 + assert response_item.json() == {"item_id": 11} + + response_user = client.get("/users/administrator") + assert response_user.status_code == 200 + assert response_user.json() == {"username": "administrator"} + + # 4. Test failure on the *first* endpoint + response_item_fail = client.get("/items/5") + assert response_item_fail.status_code == 422 + # CRITICAL: Error must be about 'item_id', not 'username' + assert "item_id" in response_item_fail.text + assert "greater than 10" in response_item_fail.text + assert "username" not in response_item_fail.text + + # 5. Test failure on the *second* endpoint + response_user_fail = client.get("/users/adm") + assert response_user_fail.status_code == 422 + assert "username" in response_user_fail.text + assert "at least 5 characters" in response_user_fail.text + assert "item_id" not in response_user_fail.text From 26308b964b7e583210e4011c7b35fc2122ace696 Mon Sep 17 00:00:00 2001 From: Mohammed Saajid S <141727149+Mohammed-Saajid@users.noreply.github.com> Date: Mon, 10 Nov 2025 14:22:18 +0530 Subject: [PATCH 25/30] Added Query Validator --- fastapi_assets/request_validators/__init__.py | 1 + .../request_validators/query_validator.py | 200 ++++++++++++++ tests/test_query_validator.py | 244 ++++++++++++++++++ 3 files changed, 445 insertions(+) create mode 100644 fastapi_assets/request_validators/query_validator.py create mode 100644 tests/test_query_validator.py diff --git a/fastapi_assets/request_validators/__init__.py b/fastapi_assets/request_validators/__init__.py index d34a237..a6ccd5e 100644 --- a/fastapi_assets/request_validators/__init__.py +++ b/fastapi_assets/request_validators/__init__.py @@ -3,3 +3,4 @@ from fastapi_assets.request_validators.header_validator import HeaderValidator from fastapi_assets.request_validators.cookie_validator import CookieValidator from fastapi_assets.request_validators.path_validator import PathValidator +from fastapi_assets.request_validators.query_validator import QueryValidator diff --git a/fastapi_assets/request_validators/query_validator.py b/fastapi_assets/request_validators/query_validator.py new file mode 100644 index 0000000..1d0fd47 --- /dev/null +++ b/fastapi_assets/request_validators/query_validator.py @@ -0,0 +1,200 @@ +"""Module providing the QueryValidator for validating query parameters in FastAPI.""" + +from typing import Any, Callable, List, Optional, Union +from inspect import Signature, Parameter +from fastapi import Query +from fastapi_assets.core import BaseValidator, ValidationError + + +class QueryValidator(BaseValidator): + r""" + A dependency factory for adding custom validation to FastAPI query parameters. + + This class extends the functionality of FastAPI's `Query()` by adding + support for `allowed_values` and custom `validators`. + + It acts as a factory: you instantiate it, and then *call* the + instance inside `Depends()` to get the actual dependency. + + Example: + .. code-block:: python + + from fastapi import FastAPI, Depends + from fastapi_assets.request_validators import QueryValidator + + app = FastAPI() + + # 1. Create reusable validator *instances* + page_validator = QueryValidator( + "page", + _type=int, + default=1, + ge=1, + le=100, + ) + + status_validator = QueryValidator( + "status", + _type=str, + allowed_values=["active", "inactive", "pending"], + ) + + sort_validator = QueryValidator( + "sort", + _type=str, + default="name", + pattern=r"^[a-zA-Z_]+$", + ) + + @app.get("/items/") + def list_items( + page: int = Depends(page_validator()), + status: str = Depends(status_validator()), + sort: str = Depends(sort_validator()), + ): + return {"page": page, "status": status, "sort": sort} + """ + + def __init__( + self, + param_name: str, + _type: type, + default: Any = ..., + *, + # Custom validation rules + allowed_values: Optional[List[Any]] = None, + validators: Optional[List[Callable[[Any], Any]]] = None, + on_custom_validator_error_detail: str = "Custom validation failed.", + # Standard Query() parameters + title: Optional[str] = None, + description: Optional[str] = None, + gt: Optional[Union[int, float]] = None, + lt: Optional[Union[int, float]] = None, + ge: Optional[Union[int, float]] = None, + le: Optional[Union[int, float]] = None, + min_length: Optional[int] = None, + max_length: Optional[int] = None, + pattern: Optional[str] = None, + deprecated: Optional[bool] = None, + **query_kwargs: Any, + ) -> None: + """ + Initializes the QueryValidator factory. + + Args: + param_name: The exact name of the query parameter. + _type: The Python type for coercion (e.g., int, str, UUID). + default: Default value for the query parameter. + allowed_values: List of allowed values. + validators: List of custom validation functions. + on_custom_validator_error_detail: Error message for custom validators. + title: Title for API documentation. + description: Description for API documentation. + gt: Value must be greater than this. + lt: Value must be less than this. + ge: Value must be greater than or equal to this. + le: Value must be less than or equal to this. + min_length: Minimum length for string parameters. + max_length: Maximum length for string parameters. + pattern: Regex pattern the parameter must match. + deprecated: Whether the parameter is deprecated. + **query_kwargs: Additional arguments passed to FastAPI's Query(). + """ + query_kwargs.setdefault("error_detail", "Query parameter validation failed.") + query_kwargs.setdefault("status_code", 400) + + super().__init__( + status_code=query_kwargs["status_code"], + error_detail=query_kwargs["error_detail"], + validators=validators, + ) + + self._param_name = param_name + self._type = _type + self._allowed_values = allowed_values + self._on_custom_validator_error_detail = on_custom_validator_error_detail + + self._query_param = Query( + default, + title=title, + description=description, + deprecated=deprecated, + gt=gt, + lt=lt, + ge=ge, + le=le, + min_length=min_length, + max_length=max_length, + pattern=pattern, + **query_kwargs, + ) + + def __call__(self) -> Callable[..., Any]: + """ + This is the factory method. + It generates and returns the dependency function + that FastAPI will use. + """ + + async def dependency(**kwargs: Any) -> Any: + query_value = kwargs[self._param_name] + try: + validated_value = await self._validate(query_value) + return validated_value + except ValidationError as e: + self._raise_error(query_value, status_code=e.status_code, detail=e.detail) + return None + + sig = Signature( + [ + Parameter( + self._param_name, + Parameter.KEYWORD_ONLY, + default=self._query_param, + annotation=self._type, + ) + ] + ) + + dependency.__signature__ = sig # type: ignore + return dependency + + async def _validate(self, value: Any) -> Any: + """ + Runs all validation checks on the query parameter value. + + Executes allowed values checking and custom validator checking in sequence. + + Args: + value: The query parameter value to validate. + + Returns: + Any: The validated value (unchanged if validation passes). + + Raises: + ValidationError: If any validation check fails. + """ + self._validate_allowed_values(value) + await self._validate_custom(value) + return value + + def _validate_allowed_values(self, value: Any) -> None: + """ + Checks if the query parameter value is in the list of allowed values. + + Args: + value: The value to validate. + + Returns: + None + + Raises: + ValidationError: If the value is not in the allowed values list. + """ + if self._allowed_values is None: + return + + if value not in self._allowed_values: + allowed_str = ", ".join(map(str, self._allowed_values)) + detail = f"Value '{value}' is not allowed. Allowed values are: {allowed_str}" + raise ValidationError(detail=detail, status_code=400) diff --git a/tests/test_query_validator.py b/tests/test_query_validator.py new file mode 100644 index 0000000..85d1074 --- /dev/null +++ b/tests/test_query_validator.py @@ -0,0 +1,244 @@ +"""Tests for the QueryValidator class.""" + +import pytest +import asyncio +from typing import Any, Callable, List, Optional, Union +from inspect import Signature, Parameter +from fastapi import FastAPI, Depends, Query, HTTPException +from fastapi.testclient import TestClient +from fastapi_assets.request_validators import QueryValidator +from fastapi_assets.core import ValidationError + + +def get_app_and_client(validator_instance: QueryValidator) -> tuple[FastAPI, TestClient]: + """Helper function to create a test app for a given validator.""" + app = FastAPI() + + @app.get("/validate/") + def validate_endpoint( + # The key is to call the instance to get the dependency function + param: Any = Depends(validator_instance()), + ): + return {"validated_param": param} + + client = TestClient(app) + return app, client + + +def test_standard_query_validation_ge(): + """ + Tests that standard Query params (like 'ge') are enforced by FastAPI + before our custom validation runs. This should result in a 422 error. + """ + page_validator = QueryValidator("page", _type=int, default=1, ge=1) + app, client = get_app_and_client(page_validator) + + # Test valid case + response = client.get("/validate/?page=5") + assert response.status_code == 200 + assert response.json() == {"validated_param": 5} + + # Test invalid case (FastAPI's built-in 'ge' validation) + response = client.get("/validate/?page=0") + assert response.status_code == 422 # Unprocessable Entity + assert "greater than or equal to 1" in response.text + + +def test_standard_query_validation_type_error(): + """ + Tests that FastAPI's type coercion and validation fail first. + """ + page_validator = QueryValidator("page", _type=int, ge=1) + app, client = get_app_and_client(page_validator) + + response = client.get("/validate/?page=not-an-integer") + assert response.status_code == 422 + detail = response.json()["detail"] + # Check if detail is a list (modern Pydantic v2) or a string (older format) + if isinstance(detail, list): + assert any("integer" in str(error.get("msg", "")).lower() for error in detail) + else: + assert "integer" in str(detail).lower() + + +def test_required_parameter_missing(): + """ + Tests that a parameter without a default is correctly marked as required. + """ + # Note: `default=...` is the default, making it required + token_validator = QueryValidator("token", _type=str) + app, client = get_app_and_client(token_validator) + + # Test missing required parameter + response = client.get("/validate/") + assert response.status_code == 422 + assert "Field required" in response.text + + # Test providing the parameter + response = client.get("/validate/?token=abc") + assert response.status_code == 200 + assert response.json() == {"validated_param": "abc"} + + +def test_default_value_is_used(): + """ + Tests that the default value is used when the parameter is omitted. + """ + page_validator = QueryValidator("page", _type=int, default=1, ge=1) + app, client = get_app_and_client(page_validator) + + response = client.get("/validate/") + assert response.status_code == 200 + assert response.json() == {"validated_param": 1} + + +def test_allowed_values_success(): + """ + Tests that a value in the 'allowed_values' list passes validation. + """ + status_validator = QueryValidator("status", _type=str, allowed_values=["active", "pending"]) + app, client = get_app_and_client(status_validator) + + response_active = client.get("/validate/?status=active") + assert response_active.status_code == 200 + assert response_active.json() == {"validated_param": "active"} + + response_pending = client.get("/validate/?status=pending") + assert response_pending.status_code == 200 + assert response_pending.json() == {"validated_param": "pending"} + + +def test_allowed_values_failure(): + """ + Tests that a value NOT in the 'allowed_values' list fails with a 400. + """ + status_validator = QueryValidator("status", _type=str, allowed_values=["active", "pending"]) + app, client = get_app_and_client(status_validator) + + response = client.get("/validate/?status=archived") + assert response.status_code == 400 # Bad Request + detail = response.json()["detail"] + assert "Value 'archived' is not allowed" in detail + assert "Allowed values are: active, pending" in detail + + +def test_custom_sync_validator_success(): + """ + Tests a passing synchronous custom validator. + """ + + def is_even(v: int): + if not v % 2 == 0: + raise ValidationError("Not Even") + + num_validator = QueryValidator("num", _type=int, validators=[is_even]) + app, client = get_app_and_client(num_validator) + + response = client.get("/validate/?num=10") + assert response.status_code == 200 + assert response.json() == {"validated_param": 10} + + +def test_custom_sync_validator_failure_with_validation_error(): + """ + Tests a failing synchronous custom validator that raises ValidationError. + """ + + def must_be_even(v: int): + if v % 2 != 0: + raise ValidationError(detail="Value must be even.", status_code=400) + + num_validator = QueryValidator("num", _type=int, validators=[must_be_even]) + app, client = get_app_and_client(num_validator) + + response = client.get("/validate/?num=7") + assert response.status_code == 400 + assert "Value must be even." in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_custom_async_validator_success(): + """ + Tests a passing asynchronous custom validator. + """ + + async def async_check_pass(v: str): + await asyncio.sleep(0) + return v == "valid" + + key_validator = QueryValidator("key", _type=str, validators=[async_check_pass]) + app, client = get_app_and_client(key_validator) + + response = client.get("/validate/?key=valid") + assert response.status_code == 200 + assert response.json() == {"validated_param": "valid"} + + +@pytest.mark.asyncio +async def test_custom_async_validator_failure_with_validation_error(): + """ + Tests a failing asynchronous custom validator that raises ValidationError. + """ + + async def async_check_fail(v: str): + await asyncio.sleep(0) + if v != "valid": + raise ValidationError(detail="Key is not valid.", status_code=400) + + key_validator = QueryValidator("key", _type=str, validators=[async_check_fail]) + app, client = get_app_and_client(key_validator) + + response = client.get("/validate/?key=invalid") + assert response.status_code == 400 + assert "Key is not valid." in response.json()["detail"] + + +def test_custom_validator_failure_silent(): + """ + Tests a validator that fails by returning 'False' and checks that + 'on_custom_validator_error_detail' is used. + """ + + def silent_fail(v: str): + if not v == "must-be-this": + raise ValidationError("Value did not match required string.") + + error_msg = "Value did not match required string." + key_validator = QueryValidator( + "key", _type=str, validators=[silent_fail], on_custom_validator_error_detail=error_msg + ) + app, client = get_app_and_client(key_validator) + + response = client.get("/validate/?key=wrong-string") + assert response.status_code == 400 + assert error_msg in response.json()["detail"] + + +def test_validation_order(): + """ + Tests that 'allowed_values' check runs before 'validators'. + """ + + def should_not_be_called(v: str): + """This validator should fail, but it shouldn't even be reached.""" + if v == "beta": + raise ValidationError(detail="Custom validator was called.", status_code=400) + return + + validator = QueryValidator( + "version", + _type=str, + allowed_values=["alpha", "gamma"], # "beta" is not allowed + validators=[should_not_be_called], + ) + app, client = get_app_and_client(validator) + + # This request should fail at the 'allowed_values' check + response = client.get("/validate/?version=beta") + + # It should be a 400 Bad Request + assert response.status_code == 400 + + # The error detail should be from _validate_allowed_values, NOT the custom validator + assert "Value 'beta' is not allowed" in response.json()["detail"] + assert "Custom validator was called" not in response.json()["detail"] From 3e78b8b622af864d2eec449220921152c1a3ed01 Mon Sep 17 00:00:00 2001 From: Mohammed Saajid S <141727149+Mohammed-Saajid@users.noreply.github.com> Date: Tue, 11 Nov 2025 10:13:39 +0530 Subject: [PATCH 26/30] Documentation Updated --- docs/CODE_OF_CONDUCT.md | 45 +++ docs/CONTRIBUTING.md | 124 +++++++ docs/api-reference.md | 270 +++++++++++++++- docs/custom_validators.md | 66 ++++ docs/examples.md | 666 +++++++++++++++++++++++++++++++++++++- docs/getting-started.md | 404 ++++++++++++++++++++++- docs/index.md | 122 ++++++- mkdocs.yml | 3 + 8 files changed, 1682 insertions(+), 18 deletions(-) create mode 100644 docs/CODE_OF_CONDUCT.md create mode 100644 docs/CONTRIBUTING.md create mode 100644 docs/custom_validators.md diff --git a/docs/CODE_OF_CONDUCT.md b/docs/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..7442438 --- /dev/null +++ b/docs/CODE_OF_CONDUCT.md @@ -0,0 +1,45 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies within all project spaces, and it also applies when an individual is representing the project or its community in public spaces. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project maintainer using any of the [private contact addresses](https://github.com/dec0dOS/amazing-github-template#support). All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 1.4, available at + +For answers to common questions about this code of conduct, see diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 0000000..ec3a53c --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,124 @@ +# Contributing to FastAPI Assets +Thank you for your interest in contributing to FastAPI Assets! We welcome contributions from the community to help improve and expand this project. Whether you're fixing bugs, adding new features, or improving documentation, your contributions are valuable. + +## Reporting Bugs + +Before creating bug reports, check the issue list. When creating a bug report, include: + +- **Use a clear descriptive title** +- **Describe the exact steps to reproduce the problem** +- **Provide specific examples to demonstrate the steps** +- **Describe the behavior you observed and what the problem is** +- **Explain which behavior you expected to see instead** +- **Include Python version, FastAPI Assets version, and OS information** +- **Include relevant error messages and stack traces** + +## Suggesting Enhancements + +Enhancement suggestions are tracked as GitHub issues. When creating an enhancement suggestion, include: + +- **Use a clear descriptive title** +- **Provide a step-by-step description of the suggested enhancement** +- **Provide specific examples to demonstrate the steps** +- **Describe the current behavior and expected behavior** +- **Explain why this enhancement would be useful** + +## Pull Requests + +- Fill in the required template +- Include appropriate test cases +- Update documentation and examples +- End all files with a newline + +## Development Setup + +### Prerequisites + +- Python 3.12 or higher +- Git +- pip + +### Setting Up Development Environment + +1. **Fork the repository** + +```bash +# Go to the GitHub repository and click "Fork" +``` + +2. **Clone your fork** + +```bash +git clone https://github.com/YOUR_USERNAME/fastapi-assets.git +cd fastapi-assets +``` + +3. **Create a virtual environment** + +```bash +python -m venv venv + +# Activate virtual environment +# On Windows: +venv\Scripts\activate +# On Unix/macOS: +source venv/bin/activate +``` + +4. **Install dependencies with development extras** + +```bash +pip install -e ".[dev,image,pandas]" +``` + +5. **Create a feature branch** + +```bash +git checkout -b feature/your-feature-name +``` + +## Development Workflow + +### Code Style + +FastAPI Assets uses: +- **Ruff** for linting and formatting +- **MyPy** for type checking +- **Pytest** for testing + +### Running Linting + +```bash +ruff check fastapi_assets tests +ruff format fastapi_assets tests +``` + +### Running Type Checks + +```bash +mypy fastapi_assets +``` + +### Running Tests + +```bash +pytest +pytest --cov=fastapi_assets +pytest tests/test_file_validator.py -v +``` + +## Making Changes + +1. Create a feature branch +2. Make your changes with type hints and docstrings +3. Format your code with ruff +4. Run tests to ensure everything passes +5. Run type checks with mypy +6. Commit with clear messages following conventional commits +7. Push to your fork +8. Create a pull request + +## Code of Conduct + +By participating in this project, you agree to abide by the Contributor Covenant Code of Conduct. + diff --git a/docs/api-reference.md b/docs/api-reference.md index cb57ed9..0aa33a6 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1,17 +1,271 @@ # API Reference -This section provides a comprehensive reference for all the classes, functions, and methods available in the FastAPI Assets library. Use this guide to understand the capabilities of each component and how to utilize them effectively in your FastAPI applications. -## Table of Contents -- [Validators](#validators) -- [Utilities](#utilities) -- [Exceptions](#exceptions) +Complete API documentation for FastAPI Assets validators and utilities. -## Validators +## Core Module ### BaseValidator -The `BaseValidator` class serves as the foundational validator from which all other validators inherit. It provides common functionality for validating file uploads and request metadata. -## Utilities +**Module**: `fastapi_assets.core.base_validator` + +Abstract base class for all validators in FastAPI Assets. Provides standardized error handling and HTTP exception raising. + +#### Class Definition + +```python +class BaseValidator(abc.ABC): + """Abstract base class for creating reusable FastAPI validation dependencies.""" +``` + +#### Parameters + +- **status_code** (int, default: 400): HTTP status code to return on validation failure +- **error_detail** (Union[str, Callable], default: "Validation failed."): Error message or callable that generates error messages +- **validators** (Optional[List[Callable]], default: None): List of custom validation functions + +#### Methods + +##### `_raise_error()` + +Raises a standardized HTTPException with resolved error detail. + +```python +def _raise_error( + self, + value: Optional[Any] = None, + status_code: Optional[int] = None, + detail: Optional[Union[str, Callable[[Any], str]]] = None, +) -> None: + """Raises HTTPException with validated error message.""" +``` + +**Parameters**: +- `value`: The value that failed validation +- `status_code`: Override default status code +- `detail`: Override default error detail + +**Raises**: `HTTPException` with resolved status code and detail + +--- + +### ValidationError + +**Module**: `fastapi_assets.core.exceptions` + +Custom exception raised by validators during validation logic. + +```python +class ValidationError(Exception): + """Raised when validation logic fails.""" + + def __init__( + self, + detail: str, + status_code: int = 400 + ): + self.detail = detail + self.status_code = status_code +``` + +--- + +## File Validators + +### FileValidator + +**Module**: `fastapi_assets.validators.file_validator` + +General-purpose validator for file uploads with size, MIME type, and filename validation. + +#### Class Definition + +```python +class FileValidator(BaseValidator): + """Validates UploadFile objects for size, MIME type, and filename patterns.""" +``` + +#### Parameters + +- **max_size** (Optional[Union[str, int]], default: None): Maximum file size (e.g., "10MB", 1024) +- **min_size** (Optional[Union[str, int]], default: None): Minimum file size (e.g., "1KB") +- **content_types** (Optional[List[str]], default: None): Allowed MIME types with wildcard support +- **filename_pattern** (Optional[str], default: None): Regex pattern for filename validation +- **on_size_error_detail** (Optional[Union[str, Callable]], default: None): Custom error message for size validation +- **on_type_error_detail** (Optional[Union[str, Callable]], default: None): Custom error message for content type +- **on_filename_error_detail** (Optional[Union[str, Callable]], default: None): Custom error message for filename +- **status_code** (int, default: 400): HTTP status code on validation failure +- **error_detail** (Union[str, Callable], default: "Validation failed"): Default error message +- **validators** (Optional[List[Callable]], default: None): List of custom validators + +#### Size Format Support + +| Format | Example | Bytes | +|--------|---------|-------| +| Bytes | `"1024"` or `"1024B"` | 1024 | +| Kilobytes | `"5KB"` or `"5 KB"` | 5120 | +| Megabytes | `"10MB"` or `"10 MB"` | 10485760 | +| Gigabytes | `"1GB"` or `"1 GB"` | 1073741824 | +| Terabytes | `"1TB"` or `"1 TB"` | 1099511627776 | + +#### MIME Type Wildcards + +| Pattern | Matches | +|---------|---------| +| `"image/*"` | All image types (jpeg, png, gif, etc.) | +| `"text/*"` | All text types (plain, html, css, etc.) | +| `"application/*"` | All application types | +| `"image/jpeg"` | Specific MIME type only | + +--- + +### ImageValidator + +**Module**: `fastapi_assets.validators.image_validator` + +Specialized validator for image files with format, dimensions, and aspect ratio validation. + +#### Parameters + +Inherits all FileValidator parameters plus: + +- **allowed_formats** (Optional[List[str]], default: None): Allowed image formats (e.g., ["JPEG", "PNG"]) +- **min_resolution** (Optional[Tuple[int, int]], default: None): Minimum image resolution (width, height) +- **max_resolution** (Optional[Tuple[int, int]], default: None): Maximum image resolution (width, height) +- **aspect_ratios** (Optional[List[str]], default: None): Allowed aspect ratios (e.g., ["1:1", "16:9"]) +- **on_format_error_detail** (Optional[Union[str, Callable]], default: None): Custom error for format +- **on_dimension_error_detail** (Optional[Union[str, Callable]], default: None): Custom error for dimensions +- **on_aspect_ratio_error_detail** (Optional[Union[str, Callable]], default: None): Custom error for aspect ratio + +#### Supported Formats + +- JPEG / JPG +- PNG +- GIF +- WebP +- BMP +- TIFF + +--- + +### CSVValidator + +**Module**: `fastapi_assets.validators.csv_validator` + +Specialized validator for CSV files with schema, encoding, and row count validation. + +**Requirements**: Install with `pip install fastapi-assets[pandas]` + +#### Parameters + +Inherits all FileValidator parameters plus: + +- **encoding** (Optional[Union[str, List[str]]], default: None): Allowed encoding(s) for CSV +- **delimiter** (Optional[str], default: None): CSV delimiter character +- **required_columns** (Optional[List[str]], default: None): Columns that must exist +- **disallowed_columns** (Optional[List[str]], default: None): Columns that must not exist +- **min_rows** (Optional[int], default: None): Minimum number of data rows +- **max_rows** (Optional[int], default: None): Maximum number of data rows +- **header_check_only** (bool, default: False): Only validate headers without checking all rows +- **on_encoding_error_detail** (Optional[Union[str, Callable]], default: None): Custom encoding error +- **on_columns_error_detail** (Optional[Union[str, Callable]], default: None): Custom columns error +- **on_rows_error_detail** (Optional[Union[str, Callable]], default: None): Custom rows error + +--- + +## Request Validators + +### QueryValidator + +**Module**: `fastapi_assets.request_validators.query_validator` + +Validator for FastAPI query parameters with support for allowed values and constraints. + +#### Parameters + +- **param_name** (str): Name of the query parameter +- **_type** (type, default: str): Parameter type (int, str, float, bool, etc.) +- **default** (Optional[Any], default: None): Default value if parameter is missing +- **allowed_values** (Optional[List[Any]], default: None): List of allowed values +- **pattern** (Optional[str], default: None): Regex pattern for string validation +- **ge** (Optional[Union[int, float]], default: None): Greater than or equal to +- **le** (Optional[Union[int, float]], default: None): Less than or equal to +- **gt** (Optional[Union[int, float]], default: None): Greater than +- **lt** (Optional[Union[int, float]], default: None): Less than +- **error_detail** (Union[str, Callable], default: "Validation failed"): Error message +- **status_code** (int, default: 400): HTTP status code on validation failure + +--- + +### HeaderValidator + +**Module**: `fastapi_assets.request_validators.header_validator` + +Comprehensive validator for HTTP headers with pattern matching, format validation, allowed values, and custom validators. Extends FastAPI's `Header` dependency with granular error control. + +#### Class Definition + +```python +class HeaderValidator(BaseValidator): + """ + A dependency for validating HTTP headers with extended rules. + + Provides pattern matching, format validation, allowed values, + and custom validators with fine-grained error messages. + """ +``` + +#### Parameters + +- **default** (Any, default: Undefined): Default value if header is missing. If not provided, header is required. +- **alias** (Optional[str], default: None): The actual header name to extract (e.g., "X-API-Key", "Authorization") +- **convert_underscores** (bool, default: True): If True, underscores in parameter name convert to hyphens in header name +- **pattern** (Optional[str], default: None): Regex pattern that header value must match (cannot be used with `format`) +- **format** (Optional[str], default: None): Predefined format name for validation +- **allowed_values** (Optional[List[str]], default: None): List of exact string values allowed for the header +- **validators** (Optional[List[Callable]], default: None): List of custom validation functions (sync or async) +- **title** (Optional[str], default: None): Title for the header in OpenAPI documentation +- **description** (Optional[str], default: None): Description for the header in OpenAPI documentation +- **on_required_error_detail** (str, default: "Required header is missing."): Error message if header is missing +- **on_pattern_error_detail** (str, default: "Header has an invalid format."): Error message if pattern/format fails +- **on_allowed_values_error_detail** (str, default: "Header value is not allowed."): Error message if value not in allowed list +- **on_custom_validator_error_detail** (str, default: "Header failed custom validation."): Error message if custom validator fails +- **status_code** (int, default: 400): HTTP status code for validation errors +- **error_detail** (str, default: "Header Validation Failed"): Generic fallback error message + + +--- + +### CookieValidator + +**Module**: `fastapi_assets.request_validators.cookie_validator` + +Validator for cookie values with pattern matching and constraints. + +#### Parameters + +- **alias** (str): Name of the cookie to validate +- **pattern** (Optional[str], default: None): Regex pattern for cookie value +- **error_detail** (Union[str, Callable], default: "Validation failed"): Error message +- **status_code** (int, default: 400): HTTP status code on validation failure + +--- + +### PathValidator + +**Module**: `fastapi_assets.request_validators.path_validator` + +Validator for path parameters with type conversion and pattern matching. + +#### Parameters + +- **param_name** (str): Name of the path parameter +- **_type** (type, default: str): Parameter type +- **pattern** (Optional[str], default: None): Regex pattern for validation +- **error_detail** (Union[str, Callable], default: "Validation failed"): Error message +- **status_code** (int, default: 400): HTTP status code on validation failure + +--- + ## Exceptions ### ValidationError diff --git a/docs/custom_validators.md b/docs/custom_validators.md new file mode 100644 index 0000000..b44aafd --- /dev/null +++ b/docs/custom_validators.md @@ -0,0 +1,66 @@ +# Custom Validators +FastAPI Assets allows you to create custom validators by extending the base validator classes. This enables you to implement specific validation logic tailored to your application's needs. You can also create custom validation functions to be used with existing validators. Let's explore how to create and use custom validators. + +### Creating a Custom File Validator + +To create a custom file validator, you can subclass the `FileValidator` and override the `validate` method to include your custom logic. Here's an example of a custom file validator that checks for a specific filename pattern: + +```python +import re +from fastapi_assets.validators import FileValidator +class CustomFileValidator(FileValidator): + def __init__(self, filename_pattern: str, **kwargs): + super().__init__(**kwargs) + self.filename_pattern = re.compile(filename_pattern) + + async def validate(self, file): + await super().validate(file) # Perform standard validations + if not self.filename_pattern.match(file.filename): + raise ValueError(f"Filename '{file.filename}' does not match the required pattern.") +``` + +You can then use this custom validator in your FastAPI routes: + +```python +from fastapi import FastAPI, UploadFile, Depends +app = FastAPI() +custom_validator = CustomFileValidator( + filename_pattern=r"^[\w\s-]+\.(jpg|jpeg|png|gif)$", + max_size="5MB", + content_types=["image/jpeg", "image/png", "image/gif"] +) +@app.post("/upload/custom/") +async def upload_custom_file(file: UploadFile = Depends(custom_validator)): + return {"filename": file.filename, "size": file.size} +``` +### Creating a Custom Validation Function +You can also create custom validation functions that can be passed to existing validators. For example, let's create a custom function to validate that a file's size is an exact multiple of 1024 bytes: + +```python +from fastapi_assets.validators import FileValidator +from fastapi_assets.core import ValidationError +def exact_multiple_of_1024(file): + if file.size % 1024 != 0: + raise ValidationError("File size must be an exact multiple of 1024 bytes.") +file_validator = FileValidator( + max_size="10MB", + content_types=["application/octet-stream"], + validators=[exact_multiple_of_1024] +) +``` + +You can then use this validator in your FastAPI routes: + +```python +from fastapi import FastAPI, UploadFile, Depends +app = FastAPI() +@app.post("/upload/exact-multiple/") +async def upload_exact_multiple_file(file: UploadFile = Depends(file_validator)): + return {"filename": file.filename, "size": file.size} +``` + +### Summary +Creating custom validators in FastAPI Assets allows you to implement specific validation logic that suits your application's requirements. By subclassing existing validators or creating custom validation functions, you can enhance the validation capabilities of your FastAPI applications. + +## Contributing to FastAPI Assets +Thank you for your interest in contributing to FastAPI Assets! We welcome contributions from the community to help improve and expand this project. Whether you're fixing bugs, adding new features, or improving documentation, your contributions are valuable. diff --git a/docs/examples.md b/docs/examples.md index 8bdf483..c670f63 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -1,2 +1,664 @@ -# Examples -This document provides several examples to illustrate the usage of the features described in this project. Each example includes a brief description, the code snippet, and the expected output. +# Examples & Use Cases + +This document provides practical examples demonstrating how to use FastAPI Assets in real-world scenarios. + +## Table of Contents + +1. [File Upload Examples](#file-upload-examples) +2. [Image Processing](#image-processing) +3. [CSV Import](#csv-import) +4. [Request Parameter Validation](#request-parameter-validation) +5. [Error Handling](#error-handling) +6. [Advanced Patterns](#advanced-patterns) + +## File Upload Examples + +### Basic File Upload + +Simple file upload with size and type validation: + +```python +from fastapi import FastAPI, UploadFile, Depends +from fastapi_assets.validators import FileValidator + +app = FastAPI() + +# Create a simple file validator +file_validator = FileValidator( + max_size="10MB", + content_types=["application/pdf"] +) + +@app.post("/upload/pdf/") +async def upload_pdf(file: UploadFile = Depends(file_validator)): + """Upload a PDF file.""" + return { + "filename": file.filename, + "content_type": file.content_type, + "size": file.size + } +``` + +### Multiple File Type Support + +Accepting multiple file types with different validators: + +```python +from fastapi import FastAPI, UploadFile, Depends +from fastapi_assets.validators import FileValidator + +app = FastAPI() + +# PDF validator +pdf_validator = FileValidator( + max_size="20MB", + content_types=["application/pdf"] +) + +# Image validator +image_validator = FileValidator( + max_size="5MB", + content_types=["image/jpeg", "image/png", "image/gif"] +) + +# Document validator +doc_validator = FileValidator( + max_size="15MB", + content_types=[ + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ] +) + +@app.post("/upload/document/") +async def upload_document(file: UploadFile = Depends(pdf_validator)): + return {"type": "pdf", "filename": file.filename} + +@app.post("/upload/image/") +async def upload_image(file: UploadFile = Depends(image_validator)): + return {"type": "image", "filename": file.filename} + +@app.post("/upload/document-word/") +async def upload_word_document(file: UploadFile = Depends(doc_validator)): + return {"type": "word", "filename": file.filename} +``` + +### Filename Validation + +Validating filenames with regex patterns: + +```python +from fastapi_assets.validators import FileValidator + +# Only allow alphanumeric filenames with specific extensions +strict_filename_validator = FileValidator( + max_size="5MB", + filename_pattern=r"^[a-zA-Z0-9_\-]+\.(pdf|doc|docx|txt)$", + on_filename_error_detail="Filename must contain only letters, numbers, hyphens, and underscores" +) + +@app.post("/upload/") +async def upload_with_filename_validation( + file: UploadFile = Depends(strict_filename_validator) +): + return {"filename": file.filename} +``` + +--- + +## Image Processing + +### Profile Photo Upload + +Validate profile photos with size and dimension constraints: + +```python +from fastapi_assets.validators import ImageValidator + +# Profile photo validator +profile_validator = ImageValidator( + max_size="2MB", + min_size="100KB", + allowed_formats=["JPEG", "PNG"], + min_resolution=(200, 200), + max_resolution=(4000, 4000), + on_size_error_detail="Profile photo must be between 100KB and 2MB", + on_dimension_error_detail="Image must be at least 200x200 pixels" +) + +@app.post("/profile/photo/") +async def upload_profile_photo( + file: UploadFile = Depends(profile_validator) +): + """Upload and validate a profile photo.""" + return { + "filename": file.filename, + "status": "validated", + "message": "Profile photo updated successfully" + } +``` + +### Avatar Upload + +Strict square image validation for avatars: + +```python +# Avatar validator - strict square format +avatar_validator = ImageValidator( + max_size="512KB", + allowed_formats=["PNG"], + min_resolution=(64, 64), + max_resolution=(256, 256), + aspect_ratios=["1:1"], +) + +@app.post("/avatar/") +async def upload_avatar(file: UploadFile = Depends(avatar_validator)): + """Upload user avatar.""" + return {"filename": file.filename, "type": "avatar"} +``` + +### Banner/Hero Image + +Landscape image validation: + +```python +# Banner validator - landscape format +banner_validator = ImageValidator( + max_size="10MB", + allowed_formats=["JPEG", "PNG", "WebP"], + min_resolution=(1920, 1080), + aspect_ratios=["16:9", "4:3"], +) + +@app.post("/banner/") +async def upload_banner(file: UploadFile = Depends(banner_validator)): + """Upload banner image.""" + return {"filename": file.filename, "type": "banner"} +``` + +### Image Gallery Upload + +Multiple image format support: + +```python +# Gallery validator - flexible dimensions +gallery_validator = ImageValidator( + max_size="8MB", + allowed_formats=["JPEG", "PNG", "WebP", "GIF"], + min_resolution=(800, 600), + max_resolution=(5000, 5000), +) + +@app.post("/gallery/images/") +async def upload_gallery_image(file: UploadFile = Depends(gallery_validator)): + """Upload image to gallery.""" + return {"filename": file.filename, "type": "gallery"} +``` + +--- + +## CSV Import + +### User Data Import + +Import user data from CSV with schema validation: + +```python +from fastapi_assets.validators import CSVValidator + +# User data CSV validator +user_csv_validator = CSVValidator( + max_size="5MB", + encoding="utf-8", + delimiter=",", + required_columns=["id", "name", "email", "phone"], + min_rows=1, + max_rows=10000, + on_columns_error_detail="CSV must contain: id, name, email, phone" +) + +@app.post("/import/users/") +async def import_users(file: UploadFile = Depends(user_csv_validator)): + """Import users from CSV.""" + # In real app, you would parse and import the data + return { + "filename": file.filename, + "status": "imported", + "message": "Users imported successfully" + } +``` + +### Sales Data Import + +CSV with flexible encoding support: + +```python +# Sales data validator +sales_validator = CSVValidator( + max_size="50MB", + encoding=["utf-8", "latin-1"], # Support multiple encodings + delimiter=",", + required_columns=["date", "product_id", "quantity", "price"], + disallowed_columns=["internal_notes", "cost"], + min_rows=1, + max_rows=100000, + header_check_only=True # Don't validate every row for performance +) + +@app.post("/import/sales/") +async def import_sales(file: UploadFile = Depends(sales_validator)): + """Import sales data from CSV.""" + return { + "filename": file.filename, + "status": "imported", + "message": "Sales data imported successfully" + } +``` + +### Student Records Import + +CSV with strict column validation: + +```python +# Student records validator +student_csv_validator = CSVValidator( + max_size="10MB", + encoding="utf-8", + delimiter=",", + required_columns=["student_id", "first_name", "last_name", "email", "grade"], + disallowed_columns=["ssn", "password"], # Never allow sensitive data + min_rows=1, + max_rows=50000 +) + +@app.post("/import/students/") +async def import_student_records(file: UploadFile = Depends(student_csv_validator)): + """Import student records.""" + return { + "filename": file.filename, + "status": "imported" + } +``` + +--- + +## Request Parameter Validation + +### Query Parameter Validation + +Validate pagination and filtering: + +```python +from fastapi_assets.request_validators import QueryValidator + +app = FastAPI() + +# Pagination parameters +page_validator = QueryValidator( + "page", + _type=int, + default=1, + ge=1, + le=1000 +) + +per_page_validator = QueryValidator( + "per_page", + _type=int, + default=20, + ge=1, + le=100 +) + +# Status filter +status_validator = QueryValidator( + "status", + _type=str, + allowed_values=["active", "inactive", "pending"], + default="active" +) + +@app.get("/items/") +async def list_items( + page: int = Depends(page_validator()), + per_page: int = Depends(per_page_validator()), + status: str = Depends(status_validator()) +): + """List items with pagination and filtering.""" + return { + "page": page, + "per_page": per_page, + "status": status, + "total": 1000 + } +``` + +### Header Validation + +Validate authorization and custom headers: + +```python +from fastapi_assets.request_validators import HeaderValidator + +app = FastAPI() + +# Authorization header validator +auth_validator = HeaderValidator( + "authorization", + pattern=r"^Bearer [A-Za-z0-9\-._~\+\/]+=*$" +) + +# API key header validator +api_key_validator = HeaderValidator( + "x-api-key", + pattern=r"^[a-f0-9]{32}$" +) + +@app.get("/protected/") +async def protected_endpoint( + auth: str = Depends(auth_validator()) +): + """Protected endpoint requiring authorization.""" + return {"status": "authorized"} + +@app.post("/api/data/") +async def api_endpoint( + api_key: str = Depends(api_key_validator()) +): + """API endpoint requiring API key.""" + return {"status": "authenticated"} +``` + +### Cookie Validation + +Validate session and preference cookies: + +```python +from fastapi_assets.request_validators import CookieValidator + +app = FastAPI() + +# Session cookie validator +session_validator = CookieValidator( + "session_id", + pattern=r"^[a-f0-9]{32}$" +) + +@app.get("/dashboard/") +async def dashboard( + session_id: str = Depends(session_validator()) +): + """Dashboard requiring valid session.""" + return {"session": session_id, "user": "john_doe"} +``` + +--- + +## Error Handling + +### Custom Error Messages + +Provide user-friendly error messages: + +```python +from fastapi_assets.validators import FileValidator + +# Validator with custom error messages +file_validator = FileValidator( + max_size="5MB", + min_size="100KB", + content_types=["application/pdf"], + on_size_error_detail="File must be between 100KB and 5MB. Please try again.", + on_type_error_detail="Only PDF files are accepted. Please upload a PDF.", + on_filename_error_detail="Filename contains invalid characters." +) + +@app.post("/upload/") +async def upload_file(file: UploadFile = Depends(file_validator)): + return {"filename": file.filename} +``` + +### Dynamic Error Messages + +Generate contextual error messages: + +```python +from fastapi_assets.validators import FileValidator + +def format_size_error(size_bytes): + # Convert bytes to human-readable format + for unit in ['B', 'KB', 'MB', 'GB']: + if size_bytes < 1024: + return f"File is {size_bytes:.1f}{unit}, but maximum is 5MB" + size_bytes /= 1024 + return "File is too large" + +file_validator = FileValidator( + max_size="5MB", + on_size_error_detail=format_size_error +) + +@app.post("/upload/") +async def upload_file(file: UploadFile = Depends(file_validator)): + return {"filename": file.filename} +``` + +### Global Exception Handler + +Custom error response format: + +```python +from fastapi import FastAPI, HTTPException +from fastapi.responses import JSONResponse + +app = FastAPI() + +@app.exception_handler(HTTPException) +async def http_exception_handler(request, exc): + """Custom HTTP exception handler.""" + return JSONResponse( + status_code=exc.status_code, + content={ + "success": False, + "error": exc.detail, + "path": str(request.url.path), + "timestamp": str(datetime.now()) + } + ) +``` + +--- + +## Advanced Patterns + +### Reusable Validator Configuration + +Create a module for centralized validator definitions: + +```python +# validators.py +from fastapi_assets.validators import FileValidator, ImageValidator, CSVValidator + +# Profile validators +profile_photo = ImageValidator( + max_size="2MB", + allowed_formats=["JPEG", "PNG"], + min_resolution=(200, 200) +) + +profile_banner = ImageValidator( + max_size="5MB", + allowed_formats=["JPEG", "PNG", "WebP"], + min_resolution=(1200, 600) +) + +# Document validators +pdf_document = FileValidator( + max_size="20MB", + content_types=["application/pdf"] +) + +resume = FileValidator( + max_size="5MB", + content_types=["application/pdf", "application/msword"], + filename_pattern=r"^[a-zA-Z0-9_\-]+\.(pdf|doc|docx)$" +) + +# Data validators +user_data_csv = CSVValidator( + max_size="10MB", + required_columns=["id", "name", "email"], + max_rows=50000 +) + +# routes.py +from fastapi import FastAPI, UploadFile, Depends +from . import validators + +app = FastAPI() + +@app.post("/profile/photo/") +async def update_profile_photo( + file: UploadFile = Depends(validators.profile_photo) +): + return {"status": "updated"} + +@app.post("/upload/resume/") +async def upload_resume( + file: UploadFile = Depends(validators.resume) +): + return {"status": "uploaded"} + +@app.post("/import/users/") +async def import_users( + file: UploadFile = Depends(validators.user_data_csv) +): + return {"status": "imported"} +``` + +### Conditional Validation + +Validate based on conditions: + +```python +from fastapi import FastAPI, UploadFile, Depends, Query +from fastapi_assets.validators import FileValidator, ImageValidator + +app = FastAPI() + +# Validators for different upload types +image_validator = ImageValidator(max_size="5MB") +document_validator = FileValidator(max_size="20MB") + +@app.post("/upload/") +async def upload_file( + file: UploadFile = Depends(), + upload_type: str = Query(..., regex="^(image|document)$") +): + """Upload file with type-specific validation.""" + if upload_type == "image": + validator = image_validator + else: + validator = document_validator + + # Validate the file + validated_file = await validator(file) + + return { + "type": upload_type, + "filename": validated_file.filename + } +``` + +### Combined Validators + +Use file and request validators together: + +```python +from fastapi_assets.validators import ImageValidator +from fastapi_assets.request_validators import QueryValidator + +app = FastAPI() + +image_validator = ImageValidator( + max_size="5MB", + allowed_formats=["JPEG", "PNG"] +) + +quality_validator = QueryValidator( + "quality", + _type=str, + allowed_values=["low", "medium", "high"], + default="medium" +) + +compression_validator = QueryValidator( + "compress", + _type=bool, + default=False +) + +@app.post("/upload/image/") +async def upload_and_process_image( + file: UploadFile = Depends(image_validator), + quality: str = Depends(quality_validator()), + compress: bool = Depends(compression_validator()) +): + """Upload and process image with options.""" + return { + "filename": file.filename, + "quality": quality, + "compress": compress, + "status": "processed" + } +``` + +### Batch File Upload with Validation + +Handle multiple files with individual validation: + +```python +from typing import List + +@app.post("/upload/images/batch/") +async def upload_images_batch( + files: List[UploadFile] = Depends() +): + """Upload multiple images with validation.""" + results = [] + + for file in files: + try: + # Validate each file + validated_file = await image_validator(file) + results.append({ + "filename": file.filename, + "status": "success", + "size": file.size + }) + except Exception as e: + results.append({ + "filename": file.filename, + "status": "error", + "error": str(e) + }) + + return {"results": results} +``` + +--- + +## Best Practices + +1. **Define validators once** - Create validators in a separate module and reuse them +2. **Use meaningful error messages** - Help users understand what went wrong +3. **Set reasonable limits** - Balance security with user experience +4. **Log validation failures** - Track issues for debugging +5. **Test validators** - Unit test your validators independently +6. **Document requirements** - Clearly document file requirements in API docs +7. **Consider performance** - Use `header_check_only=True` for large CSV files + +--- + + diff --git a/docs/getting-started.md b/docs/getting-started.md index bd24500..8c3ccc6 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,9 +1,409 @@ # Getting Started with FastAPI Assets -Welcome to the FastAPI Assets getting started guide! This document will help you set up and integrate FastAPI Assets into your FastAPI application quickly and easily. + +Welcome to FastAPI Assets! This comprehensive guide will help you install, configure, and start using FastAPI Assets in your FastAPI applications. + +## Table of Contents + +1. [Installation](#installation) +2. [Basic Setup](#basic-setup) +3. [Your First Validator](#your-first-validator) +4. [Common Patterns](#common-patterns) +5. [Error Handling](#error-handling) +6. [Next Steps](#next-steps) ## Installation -To install FastAPI Assets, you can use pip. Run the following command in your terminal: + +### Requirements + +- Python 3.12 or higher +- FastAPI 0.119.1 or higher + +### Basic Installation + +To install FastAPI Assets with core functionality: ```bash pip install fastapi-assets ``` + +### Installation with Optional Dependencies + +For image validation support: + +```bash +pip install fastapi-assets[image] +``` + +For CSV validation support: + +```bash +pip install fastapi-assets[pandas] +``` + +For all features: + +```bash +pip install fastapi-assets[image,pandas] +``` + +### Verify Installation + +To verify that FastAPI Assets is installed correctly: + +```python +import fastapi_assets +print(fastapi_assets.__version__) +``` + +## Basic Setup + +### Minimal FastAPI Application + +Here's a minimal FastAPI application with FastAPI Assets: + +```python +from fastapi import FastAPI, UploadFile, Depends +from fastapi_assets.validators import FileValidator + +app = FastAPI() + +# Create a file validator instance +file_validator = FileValidator( + max_size="10MB", + content_types=["image/jpeg", "image/png"] +) + +@app.post("/upload/") +async def upload_file(file: UploadFile = Depends(file_validator)): + """Upload and validate a file.""" + return { + "filename": file.filename, + "content_type": file.content_type + } + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) +``` + +## Your First Validator + +### File Upload Validation + +FileValidator is the foundation for all file validation in FastAPI Assets. Here's how to use it: + +```python +from fastapi import FastAPI, UploadFile, Depends +from fastapi_assets.validators import FileValidator + +app = FastAPI() + +# Create validators for different use cases +profile_photo_validator = FileValidator( + max_size="2MB", + min_size="10KB", + content_types=["image/jpeg", "image/png", "image/gif"], + filename_pattern=r"^[\w\s-]+\.(jpg|jpeg|png|gif)$" +) + +document_validator = FileValidator( + max_size="20MB", + content_types=["application/pdf", "application/msword"] +) + +@app.post("/upload/profile-photo/") +async def upload_profile_photo( + file: UploadFile = Depends(profile_photo_validator) +): + """Upload a profile photo with validation.""" + return {"filename": file.filename, "size": file.size} + +@app.post("/upload/document/") +async def upload_document( + file: UploadFile = Depends(document_validator) +): + """Upload a document with validation.""" + return {"filename": file.filename} +``` + +### Image Validation + +For specialized image validation with Pillow: + +```python +from fastapi_assets.validators import ImageValidator + +app = FastAPI() + +image_validator = ImageValidator( + max_size="5MB", + allowed_formats=["JPEG", "PNG"], + min_resolution=(800, 600), + max_resolution=(4000, 4000), + aspect_ratios=["1:1", "16:9"] +) + +@app.post("/upload/image/") +async def upload_image(file: UploadFile = Depends(image_validator)): + """Upload and validate an image file.""" + return {"filename": file.filename, "status": "validated"} +``` + +### CSV Validation + +For CSV file validation with schema checking: + +```python +from fastapi_assets.validators import CSVValidator + +app = FastAPI() + +csv_validator = CSVValidator( + encoding="utf-8", + delimiter=",", + required_columns=["id", "name", "email"], + disallowed_columns=["password"], + min_rows=1, + max_rows=10000 +) + +@app.post("/upload/csv/") +async def upload_csv(file: UploadFile = Depends(csv_validator)): + """Upload and validate a CSV file.""" + return {"filename": file.filename, "status": "validated"} +``` + +## Common Patterns + +### Reusable Validators + +It's best practice to define validators once and reuse them: + +```python +# validators.py +from fastapi_assets.validators import FileValidator, ImageValidator + +# Profile images +profile_image_validator = ImageValidator( + max_size="2MB", + allowed_formats=["JPEG", "PNG"], + min_resolution=(200, 200) +) + +# Documents +pdf_validator = FileValidator( + max_size="10MB", + content_types=["application/pdf"] +) + +# Avatars +avatar_validator = ImageValidator( + max_size="512KB", + allowed_formats=["PNG"], + min_resolution=(64, 64), + max_resolution=(256, 256), +) +``` + +Then import and use in your routes: + +```python +# routes.py +from fastapi import FastAPI, UploadFile, Depends +from .validators import profile_image_validator, pdf_validator, avatar_validator + +app = FastAPI() + +@app.post("/profile/photo/") +async def update_profile_photo( + file: UploadFile = Depends(profile_image_validator) +): + return {"status": "uploaded"} + +@app.post("/submit/document/") +async def submit_document( + file: UploadFile = Depends(pdf_validator) +): + return {"status": "submitted"} + +@app.post("/avatar/") +async def upload_avatar( + file: UploadFile = Depends(avatar_validator) +): + return {"status": "updated"} +``` + +### Multiple Validators + +You can combine multiple validators in a single endpoint: + +```python +from fastapi_assets.validators import FileValidator, ImageValidator +from fastapi_assets.request_validators import QueryValidator + +app = FastAPI() + +image_validator = ImageValidator(max_size="5MB") +quality_validator = QueryValidator( + "quality", + _type=str, + allowed_values=["high", "medium", "low"] +) + +@app.post("/process/image/") +async def process_image( + file: UploadFile = Depends(image_validator), + quality: str = Depends(quality_validator()) +): + """Process an image with specified quality.""" + return {"filename": file.filename, "quality": quality} +``` + +### Custom Error Messages + +Customize validation error messages: + +```python +file_validator = FileValidator( + max_size="5MB", + min_size="100KB", + content_types=["image/*"], + on_size_error_detail="File must be between 100KB and 5MB", + on_type_error_detail="Only image files are allowed", + on_filename_error_detail="Filename contains invalid characters" +) +``` + +### Dynamic Error Messages + +Use callables for dynamic error messages: + +```python +def size_error_message(value): + return f"File size {value} bytes exceeds the limit" + +file_validator = FileValidator( + max_size="5MB", + on_size_error_detail=size_error_message +) +``` + +## Error Handling + +### Understanding Validation Errors + +When validation fails, FastAPI Assets raises HTTPException with appropriate status codes: + +```python +# Automatic error handling +@app.post("/upload/") +async def upload_file(file: UploadFile = Depends(file_validator)): + # If validation fails, a 400 error is automatically returned + return {"filename": file.filename} +``` + + +### Common Error Status Codes + +- **400**: Bad Request - Validation failed (default) +- **413**: Payload Too Large - File exceeds max_size +- **415**: Unsupported Media Type - Content type not allowed + +### Custom Status Codes + +```python +file_validator = FileValidator( + max_size="5MB", + status_code=422 # Unprocessable Entity +) +``` + +## Request Parameter Validation + +### Query Parameters + +```python +from fastapi_assets.request_validators import QueryValidator + +app = FastAPI() + +page_validator = QueryValidator( + "page", + _type=int, + default=1, + ge=1, + le=1000 +) + +sort_validator = QueryValidator( + "sort", + _type=str, + allowed_values=["name", "date", "size"], + default="name" +) + +@app.get("/items/") +async def list_items( + page: int = Depends(page_validator()), + sort: str = Depends(sort_validator()) +): + return {"page": page, "sort": sort} +``` + +### Header Validation + +```python +from fastapi_assets.request_validators import HeaderValidator + +app = FastAPI() + +auth_header_validator = HeaderValidator( + "authorization", + pattern=r"^Bearer [A-Za-z0-9\-._~\+\/]+=*$" +) + +@app.get("/protected/") +async def protected_endpoint( + authorization: str = Depends(auth_header_validator()) +): + return {"status": "authorized"} +``` + +### Cookie Validation + +```python +from fastapi_assets.request_validators import CookieValidator + +app = FastAPI() + +session_cookie_validator = CookieValidator( + "session_id", + pattern=r"^[a-f0-9]{32}$" +) + +@app.get("/dashboard/") +async def dashboard( + session_id: str = Depends(session_cookie_validator()) +): + return {"session": session_id} +``` + + +## Next Steps + +- Read the [API Reference](./api-reference.md) for detailed documentation on all validators +- Check out [Examples](./examples.md) for real-world use cases +- Explore [Custom Validators](./custom_validators.md) to create your own validation logic +- Review the [Contributing Guide](./CONTRIBUTING.md) if you want to contribute + +## Additional Resources + +- [FastAPI Documentation](https://fastapi.tiangolo.com/) +- [Pydantic Documentation](https://docs.pydantic.dev/) +- [Starlette Documentation](https://www.starlette.io/) + +--- + +**Need Help?** + +- Open an issue on [GitHub](https://github.com/OpenVerge/fastapi-assets) diff --git a/docs/index.md b/docs/index.md index 9fcd1b6..461fb50 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,10 +1,120 @@ -# Welcome to FastAPI Assets documentation! +# FastAPI Assets Documentation -FastAPI Assets is a powerful validation and assertion toolkit designed specifically for FastAPI applications. It simplifies the process of validating file uploads and request metadata, ensuring that your application handles user input securely and efficiently. -Explore the sections below to learn more about how to integrate FastAPI Assets into your projects and make the most of its features. +**FastAPI Assets** is a powerful, production-ready validation and assertion toolkit designed specifically for FastAPI applications. It simplifies the process of validating file uploads and request metadata, ensuring that your application handles user input securely and efficiently. -- [Getting Started](getting-started.md) +## Overview -- [API Reference](api-reference.md) +FastAPI Assets provides a comprehensive suite of validators that integrate seamlessly with FastAPI's dependency injection system. Whether you're validating file uploads, image files, CSV data, or request parameters, FastAPI Assets provides intuitive, type-safe APIs that make validation simple and robust. -- [Examples](examples.md) +### Why FastAPI Assets? + +- **Security First**: Built with security best practices to prevent common vulnerabilities +- **Performance Optimized**: Efficient streaming validation that doesn't load entire files into memory +- **Type Safe**: Full type hints and modern Python support for better IDE integration +- **Modular Design**: Use only what you need - validators are independent and composable +- **Well Documented**: Comprehensive API documentation and practical examples +- **Thoroughly Tested**: Extensive test coverage ensures reliability +- **Extensible**: Easily add custom validators and extend functionality + +## Key Features + +### File Validation +- Validate file size with flexible size format support (e.g., "10MB", "1KB") +- Check MIME types with wildcard pattern support +- Validate filename patterns with regex +- Efficient streaming for large files + +### Image Validation +- Inherit all file validation capabilities +- Verify image format and integrity using Pillow +- Validate image dimensions (width and height) +- Check aspect ratios +- Support for multiple image formats (JPEG, PNG, GIF, WebP, BMP, TIFF) + +### CSV Validation +- All file validation features +- Schema validation (required/disallowed columns) +- Encoding validation +- Row count constraints +- Delimiter customization +- Header verification +- Efficient header-only checks + +### Request Parameter Validation +- **Query Validators**: Validate query strings with allowed values and constraints +- **Header Validators**: Validate HTTP headers +- **Cookie Validators**: Validate cookie values +- **Path Validators**: Validate path segments with custom rules + +## Quick Start + +### Installation + +```bash +pip install fastapi-assets +``` + +For extended functionality: + +```bash +pip install fastapi-assets[image,pandas] +``` + +### Basic File Upload Validation + +```python +from fastapi import FastAPI, UploadFile, Depends +from fastapi_assets.validators import FileValidator + +app = FastAPI() + +# Create a file validator +file_validator = FileValidator( + max_size="10MB", + min_size="1KB", + content_types=["image/jpeg", "image/png"] +) + +@app.post("/upload/") +async def upload_file(file: UploadFile = Depends(file_validator)): + return { + "filename": file.filename, + "size": file.size, + "content_type": file.content_type + } +``` + +## Documentation + +- [Getting Started](./getting-started.md) - Installation, setup, and basic usage patterns +- [API Reference](./api-reference.md) - Complete API documentation for all validators +- [Examples](./examples.md) - Real-world usage examples and best practices +- [Contributing](./CONTRIBUTING.md) - Guidelines for contributing to the project + +## Project Structure + +``` +fastapi-assets/ +├── fastapi_assets/ +│ ├── core/ # Core validation framework +│ ├── validators/ # File, image, and CSV validators +│ └── request_validators/# Query, header, cookie, path validators +├── tests/ # Comprehensive test suite +├── docs/ # Documentation +└── pyproject.toml # Project configuration +``` + +## Requirements + +- Python 3.12+ +- FastAPI 0.119.1+ + +## Optional Dependencies + +- **image**: Pillow 12.0.0+ for image validation +- **pandas**: Pandas 2.3.3+ for CSV validation + + +## Support + +For issues, questions, or contributions, please visit the [GitHub repository](https://github.com/OpenVerge/fastapi-assets). diff --git a/mkdocs.yml b/mkdocs.yml index 58cac4a..658f048 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -22,6 +22,9 @@ nav: - Getting Started: getting-started.md - API Reference: api-reference.md - Examples: examples.md + - Contributing: CONTRIBUTING.md + - Code of Conduct: CODE_OF_CONDUCT.md + - Custom Validators: custom_validators.md # Extensions and plugins markdown_extensions: From b6e6cf6243f931e540f1bfeb302cb6fb80cf929b Mon Sep 17 00:00:00 2001 From: Mohammed Saajid S <141727149+Mohammed-Saajid@users.noreply.github.com> Date: Tue, 11 Nov 2025 10:31:22 +0530 Subject: [PATCH 27/30] Added Readme --- README.md | 157 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 156 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 355b6b6..b69b2de 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,158 @@ # FastAPI Assets -FastAPI Assets is a powerful validation and assertion toolkit designed specifically for FastAPI applications. It simplifies the process of validating file uploads and request metadata, ensuring that your application handles user input securely and efficiently. +
+ +[![Python Version](https://img.shields.io/badge/Python-3.12%2B-blue.svg)](https://www.python.org/downloads/) +[![FastAPI](https://img.shields.io/badge/FastAPI-0.119.1%2B-green.svg)](https://fastapi.tiangolo.com) +[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE.txt) +[![Code Style](https://img.shields.io/badge/Code%20Style-Ruff-black.svg)](https://github.com/astral-sh/ruff) + +A powerful validation and assertion toolkit for FastAPI applications. + +[Documentation](https://openverge.github.io/fastapi-assets/) · [Examples](./docs/examples.md) · [Contributing](./docs/CONTRIBUTING.md) + +
+ +--- + +## Overview + +**FastAPI Assets** is a comprehensive validation toolkit designed specifically for FastAPI applications. It simplifies the process of validating file uploads, request metadata, and HTTP parameters, ensuring that your application handles user input securely and efficiently. + +FastAPI Assets provides: + +- **File Validators** - Validate file uploads with size, MIME type, and filename checks +- **Image Validators** - Specialized validation for image files (format, dimensions, aspect ratio) +- **CSV Validators** - Validate CSV structure, encoding, columns, and row counts +- **Request Validators** - Validate headers, query parameters, path parameters, and cookies +- **Custom Validators** - Create custom validation logic with sync or async support +- **Granular Error Messages** - Fine-grained error control for precise user feedback +- **Type-Safe** - Full type hints and runtime type validation +- **Light Weight** - Minimal dependencies; optional extras for specific features + +## Quick Start + +### Installation + +```bash +# Basic installation +pip install fastapi-assets + +# With image support (includes Pillow) +pip install fastapi-assets[image] + +# With CSV support (includes pandas) +pip install fastapi-assets[pandas] + +# With all optional features +pip install fastapi-assets[image,pandas] +``` + +## Features + +### File Validators + +- **FileValidator** - General-purpose file validation + - File size validation (min/max with human-readable formats) + - MIME type validation with wildcard support + - Filename pattern validation + - Custom validator functions + +- **ImageValidator** - Specialized image validation + - Image format validation (JPEG, PNG, WebP, GIF, BMP, TIFF) + - Image dimension validation + - Aspect ratio validation + - Inherits all FileValidator features + +- **CSVValidator** - CSV file validation (requires pandas) + - CSV encoding validation + - Required/disallowed columns + - Row count validation + - Header-only validation option + +### Request Validators + +- **HeaderValidator** - HTTP header validation + - Pattern matching with regex + - Predefined formats (UUID4, email, Bearer token, etc.) + - Allowed values restriction + - Custom validators + +- **QueryValidator** - Query parameter validation + - Type conversion and validation + - Allowed values restriction + - Numeric range validation + - Pattern matching + +- **PathValidator** - Path parameter validation + - Type conversion + - Pattern matching + - Range validation + +- **CookieValidator** - Cookie value validation + - Pattern matching + - Required value validation + - Custom validators + +### Advanced Features + +- **Granular Error Messages** - Customize error messages for each validation type +- **Custom Validators** - Add custom validation logic (sync or async) +- **HTTP Status Codes** - Customize HTTP response codes per validator +- **Type Safety** - Full type hints for IDE support and type checking + +## Documentation + +Comprehensive documentation is available at [https://openverge.github.io/fastapi-assets/](https://openverge.github.io/fastapi-assets/) + +- [Getting Started](./docs/getting-started.md) - Installation and basic usage +- [API Reference](./docs/api-reference.md) - Complete API documentation +- [Examples](./docs/examples.md) - Practical usage examples +- [Custom Validators](./docs/custom_validators.md) - Creating custom validation logic + + +## Project Structure + +``` +fastapi-assets/ +├── fastapi_assets/ +│ ├── core/ # Core validation framework +│ │ ├── base_validator.py # Abstract base validator +│ │ └── exceptions.py # Custom exceptions +│ ├── validators/ # File validators +│ │ ├── file_validator.py +│ │ ├── image_validator.py +│ │ ├── csv_validator.py +│ │ └── utils.py +│ └── request_validators/ # HTTP request validators +│ ├── header_validator.py +│ ├── query_validator.py +│ ├── path_validator.py +│ └── cookie_validator.py +├── tests/ # Comprehensive test suite +├── docs/ # Documentation +└── pyproject.toml # Project configuration +``` + + +## Contributing + +We welcome contributions! Please see [CONTRIBUTING.md](./docs/CONTRIBUTING.md) for guidelines. + +## Code of Conduct + +Please read [CODE_OF_CONDUCT.md](./docs/CODE_OF_CONDUCT.md) before contributing. + +## License + +This project is licensed under the MIT License - see the [LICENSE.txt](LICENSE.txt) file for details. + + + +--- + +**FastAPI Assets** - Making FastAPI validation simple, secure, and intuitive. + +*Version 0.1.0* | *Last Updated: November 2025* + + From 7ea9db1dffb0432d9e4a367ff82f68c8a4fd84f9 Mon Sep 17 00:00:00 2001 From: Mohammed Saajid S <141727149+Mohammed-Saajid@users.noreply.github.com> Date: Tue, 11 Nov 2025 10:31:47 +0530 Subject: [PATCH 28/30] Minor Docstring Change --- fastapi_assets/validators/csv_validator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fastapi_assets/validators/csv_validator.py b/fastapi_assets/validators/csv_validator.py index c8860ee..2e9adfc 100644 --- a/fastapi_assets/validators/csv_validator.py +++ b/fastapi_assets/validators/csv_validator.py @@ -25,7 +25,7 @@ class CSVValidator(FileValidator): CSV-specific checks for encoding, delimiter, columns, and row counts. This validator requires the 'pandas' library. Install it with: - `pip install fastapi-asserts[csv]` + `pip install fastapi-asserts[pandas]` .. code-block:: python from fastapi import FastAPI, UploadFile, Depends @@ -95,7 +95,7 @@ def __init__( if not pd: raise ImportError( "The 'pandas' library is required for CSVValidator. " - "Install it with 'pip install fastapi-assets[csv]'" + "Install it with 'pip install fastapi-assets[pandas]'" ) kwargs["content_types"] = kwargs.get("content_types", self._DEFAULT_CSV_CONTENT_TYPES) From 3a1247620b8a684c15564eb67eaf14e71208d4fe Mon Sep 17 00:00:00 2001 From: Mohammed Saajid S <141727149+Mohammed-Saajid@users.noreply.github.com> Date: Wed, 12 Nov 2025 07:20:24 +0530 Subject: [PATCH 29/30] Minor Type Validation Changes --- fastapi_assets/validators/image_validator.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/fastapi_assets/validators/image_validator.py b/fastapi_assets/validators/image_validator.py index 8a8a4aa..9afff99 100644 --- a/fastapi_assets/validators/image_validator.py +++ b/fastapi_assets/validators/image_validator.py @@ -2,7 +2,9 @@ Module providing the ImageValidator for validating uploaded image files. """ -from typing import Any, Callable, List, Optional, Union +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Callable, List, Optional, Union from fastapi_assets.core import ValidationError from fastapi import File, UploadFile from starlette.datastructures import UploadFile as StarletteUploadFile @@ -209,7 +211,7 @@ async def _validate(self, file: UploadFile) -> None: self._validate_resolution(img) self._validate_aspect_ratio(img) - def _validate_format(self, img: Image.Image) -> None: + def _validate_format(self, img: Any) -> None: """ Validates that the image format is in the allowed list. @@ -234,7 +236,7 @@ def _validate_format(self, img: Image.Image) -> None: # 415 Unsupported Media Type raise ValidationError(detail=str(detail), status_code=415) - def _validate_resolution(self, img: Image.Image) -> None: + def _validate_resolution(self, img: Any) -> None: """ Validates the image's resolution against min, max, and exact constraints. @@ -278,7 +280,7 @@ def _validate_resolution(self, img: Image.Image) -> None: detail = self._resolution_error_detail or err_msg raise ValidationError(detail=str(detail), status_code=400) - def _validate_aspect_ratio(self, img: Image.Image) -> None: + def _validate_aspect_ratio(self, img: Any) -> None: """ Validates that the image's aspect ratio is in the allowed list. From 3568f925779b9dae0aac6b9451b53df0e7a531ba Mon Sep 17 00:00:00 2001 From: Mohammed Saajid S <141727149+Mohammed-Saajid@users.noreply.github.com> Date: Wed, 12 Nov 2025 07:53:17 +0530 Subject: [PATCH 30/30] Provided Support For Py 3.10-3.14 --- .github/workflows/ci.yaml | 4 +- .python-version | 2 +- MANIFEST.in | 30 + README.md | 4 +- docs/CONTRIBUTING.md | 2 +- docs/getting-started.md | 4 +- docs/index.md | 4 +- pyproject.toml | 202 +++-- tox.ini | 17 + uv.lock | 1578 ++++++++++++++++++++++++------------- 10 files changed, 1204 insertions(+), 643 deletions(-) create mode 100644 MANIFEST.in create mode 100644 tox.ini diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3b187ee..9cb3c10 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -10,7 +10,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v2 with: - python-version: '3.12' + python-version: '3.10' - name: Install dependencies run: | python -m pip install --upgrade pip @@ -20,7 +20,7 @@ jobs: uv pip install -e .[optional] - name: Run tests run: | - uv run pytest + uv run tox - name: Run type checks run: | uv run mypy fastapi_assets \ No newline at end of file diff --git a/.python-version b/.python-version index e4fba21..c8cfe39 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.12 +3.10 diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..c785083 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,30 @@ +include README.md +include LICENSE.txt +include pyproject.toml + +# Exclude common directories and files +global-exclude *.pyc +global-exclude *.pyo +global-exclude *.egg-info +global-exclude __pycache__ +global-exclude .pytest_cache +global-exclude .mypy_cache +global-exclude .ruff_cache +global-exclude .tox +global-exclude .venv +global-exclude .vscode +global-exclude .github +global-exclude .git +global-exclude .gitignore +global-exclude *.egg + +# Exclude directories entirely +prune build +prune dist +prune dump +prune docs +prune tests +prune site + +# Include only site HTML for documentation (optional) +recursive-include site *.html diff --git a/README.md b/README.md index b69b2de..f1db033 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@
-[![Python Version](https://img.shields.io/badge/Python-3.12%2B-blue.svg)](https://www.python.org/downloads/) -[![FastAPI](https://img.shields.io/badge/FastAPI-0.119.1%2B-green.svg)](https://fastapi.tiangolo.com) +[![Python Versions](https://img.shields.io/badge/python-3.10%20--%203.14-blue)](https://www.python.org/downloads/) +[![FastAPI](https://img.shields.io/badge/FastAPI-0.115.0%2B-green.svg)](https://fastapi.tiangolo.com) [![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE.txt) [![Code Style](https://img.shields.io/badge/Code%20Style-Ruff-black.svg)](https://github.com/astral-sh/ruff) diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index ec3a53c..71ef061 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -34,7 +34,7 @@ Enhancement suggestions are tracked as GitHub issues. When creating an enhanceme ### Prerequisites -- Python 3.12 or higher +- Python 3.10 or higher - Git - pip diff --git a/docs/getting-started.md b/docs/getting-started.md index 8c3ccc6..341191a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -15,8 +15,8 @@ Welcome to FastAPI Assets! This comprehensive guide will help you install, confi ### Requirements -- Python 3.12 or higher -- FastAPI 0.119.1 or higher +- Python 3.10 or higher +- FastAPI 0.115.0 or higher ### Basic Installation diff --git a/docs/index.md b/docs/index.md index 461fb50..d889fd0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -106,8 +106,8 @@ fastapi-assets/ ## Requirements -- Python 3.12+ -- FastAPI 0.119.1+ +- Python 3.10+ +- FastAPI 0.115.0+ ## Optional Dependencies diff --git a/pyproject.toml b/pyproject.toml index 44fcf4a..03da24d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,87 +1,115 @@ -[project] -name = "fastapi-assets" -version = "0.1.0" -description = "FastAPI Assets - A validation and assertion toolkit for FastAPI file uploads and request metadata." -readme = "README.md" -requires-python = ">=3.12" -license = { file = "MIT" } -dependencies = ["fastapi>=0.119.1"] - -[project.optional-dependencies] -pandas = ["pandas>=2.3.3"] -image = ["pillow>=12.0.0"] -dev = [ - "mkdocs>=1.6.1", - "mkdocs-material>=9.6.22", - "mkdocstrings[python]>=0.30.1", - "mypy>=1.18.2", - "pytest>=8.4.2", - "pytest-asyncio>=1.2.0", - "anyio>=4.0.0", - "httpx>=0.27.0", - "ruff>=0.14.1", -] -optional = ["fastapi-assets[pandas,image]"] - -[tool.setuptools] -packages = ["fastapi_assets"] - -[tool.setuptools.package-data] -"fastapi_assets" = ["py.typed"] - -# Pytest Configuration -[tool.pytest.ini_options] -minversion = "8.0" -addopts = "--maxfail=1 --disable-warnings" # <-- REMOVED -p no:asyncio -testpaths = ["tests"] -python_files = ["test_*.py"] -python_classes = ["Test*"] -python_functions = ["test_*"] -asyncio_mode = "auto" -markers = [ - "slow: marks tests as slow (deselect with '-m \"not slow\"')", - "integration: marks integration tests", - "anyio: marks tests that use anyio", -] - -# Ruff Configuration (Linting + Formatting) -[tool.ruff] -line-length = 100 -target-version = "py310" -extend-exclude = ["build", "dist", ".venv", "docs"] - -[tool.ruff.lint] -select = [ - "E", # pycodestyle errors - "F", # pyflakes - "B", # bugbear - "I", # import sorting - "UP", # pyupgrade - "C4", # comprehensions - "RUF",# ruff-specific -] -ignore = [ - "E501", # Ignore line-length errors (handled by formatter) -] - -[tool.ruff.format] -quote-style = "double" -indent-style = "space" -line-ending = "lf" -skip-magic-trailing-comma = false - -[tool.ruff.lint.isort] -known-first-party = ["fastapi_assets"] -combine-as-imports = true - -# Mypy Configuration (Static Type Checking) -[tool.mypy] -python_version = "3.12" -check_untyped_defs = true -disallow_untyped_defs = true -disallow_any_unimported = true -warn_unused_ignores = true -warn_redundant_casts = true -warn_unused_configs = true -strict_optional = true -ignore_missing_imports = true +[project] +name = "fastapi-assets" +version = "0.1.0" +description = "FastAPI Assets - A validation and assertion toolkit for FastAPI file uploads and request metadata." +authors = [ + {name = "Fastapi-Assets Development Team", email = "openvergecommunity@gmail.com"}, +] +readme = "README.md" +requires-python = ">=3.10" +license = "MIT" +dependencies = [ + "fastapi==0.115.0", +] + +[project.urls] +"Homepage" = "https://github.com/OpenVerge/fastapi-assets" +"Docs" = "https://openverge.github.io/fastapi-assets/" + +[project.optional-dependencies] +pandas = [ + "pandas==2.3.3", +] +image = [ + + "pillow==12.0.0", +] +dev = [ + "anyio>=4.11.0", + "httpx>=0.28.1", + "mkdocs>=1.6.1", + "mkdocs-material>=9.7.0", + "mkdocstrings[python]>=0.30.1", + "mypy>=1.18.2", + "pytest>=9.0.0", + "pytest-asyncio>=1.3.0", + "ruff>=0.14.4", + "tox>=4.32.0", + "tox-uv>=1.29.0", +] + +optional = ["fastapi-assets[pandas]", "fastapi-assets[image]"] + +[build-system] +requires = ["setuptools>=70.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["."] +include = ["fastapi_assets*"] +[tool.setuptools] +include-package-data = true + +[tool.setuptools.package-data] +"fastapi_assets" = ["py.typed"] + +# Pytest Configuration +[tool.pytest.ini_options] +minversion = "8.0" +addopts = "--maxfail=1 --disable-warnings" +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +asyncio_mode = "auto" +filterwarnings = [ + "ignore::UserWarning:argparse", +] +markers = [ + "slow: marks tests as slow (deselect with '-m \"not slow\"')", + "integration: marks integration tests", + "anyio: marks tests that use anyio", +] + +# Ruff Configuration (Linting + Formatting) +[tool.ruff] +line-length = 100 +target-version = "py310" +extend-exclude = ["build", "dist", ".venv", "docs"] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "F", # pyflakes + "B", # bugbear + "I", # import sorting + "UP", # pyupgrade + "C4", # comprehensions + "RUF",# ruff-specific +] +ignore = [ + "E501", # Ignore line-length errors (handled by formatter) +] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +line-ending = "lf" +skip-magic-trailing-comma = false + +[tool.ruff.lint.isort] +known-first-party = ["fastapi_assets"] +combine-as-imports = true + +# Mypy Configuration (Static Type Checking) +[tool.mypy] +python_version = "3.10" +check_untyped_defs = true +disallow_untyped_defs = true +disallow_any_unimported = true +warn_unused_ignores = true +warn_redundant_casts = true +warn_unused_configs = true +strict_optional = true +ignore_missing_imports = true + diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..8c8f864 --- /dev/null +++ b/tox.ini @@ -0,0 +1,17 @@ +[tox] +# The list of environments to create and test against +envlist = py310, py311, py312, py313, py314 + + +env_dir = {toxinidir}/.venv-{envname} + +[testenv] +# The dependencies to install in each environment +deps = + -e .[dev] + -e .[optional] + + +# The commands to run in each environment +commands = + pytest \ No newline at end of file diff --git a/uv.lock b/uv.lock index bbc179b..3c9f85c 100644 --- a/uv.lock +++ b/uv.lock @@ -1,14 +1,19 @@ version = 1 -revision = 1 -requires-python = ">=3.12" +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version < '3.11'", +] [[package]] name = "annotated-types" version = "0.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] [[package]] @@ -16,102 +21,162 @@ name = "anyio" version = "4.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, { name = "sniffio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094 } +sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097 }, + { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, ] [[package]] name = "babel" version = "2.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852 } +sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537 }, + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, ] [[package]] name = "backrefs" -version = "5.9" +version = "6.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/eb/a7/312f673df6a79003279e1f55619abbe7daebbb87c17c976ddc0345c04c7b/backrefs-5.9.tar.gz", hash = "sha256:808548cb708d66b82ee231f962cb36faaf4f2baab032f2fbb783e9c2fdddaa59", size = 5765857 } +sdist = { url = "https://files.pythonhosted.org/packages/91/e6/5eac48095081c358926a0cd8821351d7a013168b05cad9530fa3bcae3071/backrefs-6.0.1.tar.gz", hash = "sha256:54f8453c9ae38417a83c06d23745c634138c8da622d87a12cb3eef9ba66dd466", size = 5767249, upload-time = "2025-07-30T02:51:32.816Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/4d/798dc1f30468134906575156c089c492cf79b5a5fd373f07fe26c4d046bf/backrefs-5.9-py310-none-any.whl", hash = "sha256:db8e8ba0e9de81fcd635f440deab5ae5f2591b54ac1ebe0550a2ca063488cd9f", size = 380267 }, - { url = "https://files.pythonhosted.org/packages/55/07/f0b3375bf0d06014e9787797e6b7cc02b38ac9ff9726ccfe834d94e9991e/backrefs-5.9-py311-none-any.whl", hash = "sha256:6907635edebbe9b2dc3de3a2befff44d74f30a4562adbb8b36f21252ea19c5cf", size = 392072 }, - { url = "https://files.pythonhosted.org/packages/9d/12/4f345407259dd60a0997107758ba3f221cf89a9b5a0f8ed5b961aef97253/backrefs-5.9-py312-none-any.whl", hash = "sha256:7fdf9771f63e6028d7fee7e0c497c81abda597ea45d6b8f89e8ad76994f5befa", size = 397947 }, - { url = "https://files.pythonhosted.org/packages/10/bf/fa31834dc27a7f05e5290eae47c82690edc3a7b37d58f7fb35a1bdbf355b/backrefs-5.9-py313-none-any.whl", hash = "sha256:cc37b19fa219e93ff825ed1fed8879e47b4d89aa7a1884860e2db64ccd7c676b", size = 399843 }, - { url = "https://files.pythonhosted.org/packages/fc/24/b29af34b2c9c41645a9f4ff117bae860291780d73880f449e0b5d948c070/backrefs-5.9-py314-none-any.whl", hash = "sha256:df5e169836cc8acb5e440ebae9aad4bf9d15e226d3bad049cf3f6a5c20cc8dc9", size = 411762 }, - { url = "https://files.pythonhosted.org/packages/41/ff/392bff89415399a979be4a65357a41d92729ae8580a66073d8ec8d810f98/backrefs-5.9-py39-none-any.whl", hash = "sha256:f48ee18f6252b8f5777a22a00a09a85de0ca931658f1dd96d4406a34f3748c60", size = 380265 }, + { url = "https://files.pythonhosted.org/packages/03/c9/482590c6e687e8e962d6446c5279a4b5f498c31dd0352352e106af6fd1d7/backrefs-6.0.1-py310-none-any.whl", hash = "sha256:78a69e21b71d739b625b52b5adbf7eb1716fb4cf0a39833826f59546f321cb99", size = 381119, upload-time = "2025-07-30T02:51:21.376Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ca/7476846268a6382f0e7535fecedf81b514bdeae1404d2866040e1ec21ae3/backrefs-6.0.1-py311-none-any.whl", hash = "sha256:6ba76d616ccb02479a3a098ad1f46d92225f280d7bdce7583bc62897f32d946c", size = 392915, upload-time = "2025-07-30T02:51:23.311Z" }, + { url = "https://files.pythonhosted.org/packages/65/68/349b7d6d646d36d00aca3fd9c80082ec8991138b74046afb1895235f4ae9/backrefs-6.0.1-py312-none-any.whl", hash = "sha256:2f440f79f5ef5b9083fd366a09a976690044eca0ea0e59ac0508c3630e0ebc7c", size = 398827, upload-time = "2025-07-30T02:51:24.741Z" }, + { url = "https://files.pythonhosted.org/packages/a7/45/84853f5ce1182cc283beebd0a7f05e4210aac06b4f39192cefd60e5901b1/backrefs-6.0.1-py313-none-any.whl", hash = "sha256:62ea7e9b286808576f35b2d28a0daa09b85ae2fc71b82a951d35729b0138e66b", size = 400784, upload-time = "2025-07-30T02:51:26.577Z" }, + { url = "https://files.pythonhosted.org/packages/cb/07/2e43935cbaa0ec12d7e225e942a3c1e39fc8233f7b18100bcbffd25e6192/backrefs-6.0.1-py314-none-any.whl", hash = "sha256:3ba0d943178d24a3721c5d915734767fa93f3bde1d317c4ef9e0f33b21b9c302", size = 412645, upload-time = "2025-07-30T02:51:28.521Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9b/14e312dbbc994093caa942a3462dc9f5f54bd0770c8171c6f6aec06e8600/backrefs-6.0.1-py39-none-any.whl", hash = "sha256:b1a61b29c35cc72cfb54886164b626fbe64cab74e9d8dcac125155bd3acdb023", size = 381118, upload-time = "2025-07-30T02:51:30.749Z" }, +] + +[[package]] +name = "cachetools" +version = "6.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/7e/b975b5814bd36faf009faebe22c1072a1fa1168db34d285ef0ba071ad78c/cachetools-6.2.1.tar.gz", hash = "sha256:3f391e4bd8f8bf0931169baf7456cc822705f4e2a31f840d218f445b9a854201", size = 31325, upload-time = "2025-10-12T14:55:30.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/c5/1e741d26306c42e2bf6ab740b2202872727e0f606033c9dd713f8b93f5a8/cachetools-6.2.1-py3-none-any.whl", hash = "sha256:09868944b6dde876dfd44e1d47e18484541eaf12f26f29b7af91b26cc892d701", size = 11280, upload-time = "2025-10-12T14:55:28.382Z" }, ] [[package]] name = "certifi" version = "2025.10.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519 } +sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" }, +] + +[[package]] +name = "chardet" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618, upload-time = "2023-08-01T19:23:02.662Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286 }, + { url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385, upload-time = "2023-08-01T19:23:00.661Z" }, ] [[package]] name = "charset-normalizer" version = "3.4.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425 }, - { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162 }, - { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558 }, - { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497 }, - { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240 }, - { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471 }, - { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864 }, - { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647 }, - { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110 }, - { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839 }, - { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667 }, - { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535 }, - { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816 }, - { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694 }, - { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131 }, - { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390 }, - { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091 }, - { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936 }, - { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180 }, - { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346 }, - { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874 }, - { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076 }, - { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601 }, - { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376 }, - { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825 }, - { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583 }, - { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366 }, - { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300 }, - { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465 }, - { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404 }, - { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092 }, - { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408 }, - { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746 }, - { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889 }, - { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641 }, - { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779 }, - { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035 }, - { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542 }, - { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524 }, - { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395 }, - { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680 }, - { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045 }, - { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687 }, - { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014 }, - { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044 }, - { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940 }, - { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104 }, - { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743 }, - { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402 }, +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, + { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, + { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, + { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, + { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, + { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, + { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, + { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, + { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] [[package]] @@ -121,32 +186,53 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943 } +sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295 }, + { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, ] [[package]] name = "fastapi" -version = "0.119.1" +version = "0.115.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "starlette" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/f4/152127681182e6413e7a89684c434e19e7414ed7ac0c632999c3c6980640/fastapi-0.119.1.tar.gz", hash = "sha256:a5e3426edce3fe221af4e1992c6d79011b247e3b03cc57999d697fe76cbf8ae0", size = 338616 } +sdist = { url = "https://files.pythonhosted.org/packages/7b/5e/bf0471f14bf6ebfbee8208148a3396d1a23298531a6cc10776c59f4c0f87/fastapi-0.115.0.tar.gz", hash = "sha256:f93b4ca3529a8ebc6fc3fcf710e5efa8de3df9b41570958abf1d97d843138004", size = 302295, upload-time = "2024-09-17T19:18:12.674Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/26/e6d959b4ac959fdb3e9c4154656fc160794db6af8e64673d52759456bf07/fastapi-0.119.1-py3-none-any.whl", hash = "sha256:0b8c2a2cce853216e150e9bd4faaed88227f8eb37de21cb200771f491586a27f", size = 108123 }, + { url = "https://files.pythonhosted.org/packages/06/ab/a1f7eed031aeb1c406a6e9d45ca04bff401c8a25a30dd0e4fd2caae767c3/fastapi-0.115.0-py3-none-any.whl", hash = "sha256:17ea427674467486e997206a5ab25760f6b09e069f099b96f5b55a32fb6f1631", size = 94625, upload-time = "2024-09-17T19:18:10.962Z" }, ] [[package]] @@ -168,6 +254,8 @@ dev = [ { name = "pytest" }, { name = "pytest-asyncio" }, { name = "ruff" }, + { name = "tox" }, + { name = "tox-uv" }, ] image = [ { name = "pillow" }, @@ -182,22 +270,34 @@ pandas = [ [package.metadata] requires-dist = [ - { name = "anyio", marker = "extra == 'dev'", specifier = ">=4.0.0" }, - { name = "fastapi", specifier = ">=0.119.1" }, - { name = "fastapi-assets", extras = ["pandas", "image"], marker = "extra == 'optional'" }, - { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" }, + { name = "anyio", marker = "extra == 'dev'", specifier = ">=4.11.0" }, + { name = "fastapi", specifier = "==0.115.0" }, + { name = "fastapi-assets", extras = ["image"], marker = "extra == 'optional'" }, + { name = "fastapi-assets", extras = ["pandas"], marker = "extra == 'optional'" }, + { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28.1" }, { name = "mkdocs", marker = "extra == 'dev'", specifier = ">=1.6.1" }, - { name = "mkdocs-material", marker = "extra == 'dev'", specifier = ">=9.6.22" }, + { name = "mkdocs-material", marker = "extra == 'dev'", specifier = ">=9.7.0" }, { name = "mkdocstrings", extras = ["python"], marker = "extra == 'dev'", specifier = ">=0.30.1" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.18.2" }, - { name = "pandas", marker = "extra == 'pandas'", specifier = ">=2.3.3" }, - { name = "pillow", marker = "extra == 'image'", specifier = ">=12.0.0" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.4.2" }, - { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.2.0" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.14.1" }, + { name = "pandas", marker = "extra == 'pandas'", specifier = "==2.3.3" }, + { name = "pillow", marker = "extra == 'image'", specifier = "==12.0.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.3.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.14.4" }, + { name = "tox", marker = "extra == 'dev'", specifier = ">=4.32.0" }, + { name = "tox-uv", marker = "extra == 'dev'", specifier = ">=1.29.0" }, ] provides-extras = ["pandas", "image", "dev", "optional"] +[[package]] +name = "filelock" +version = "3.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, +] + [[package]] name = "ghp-import" version = "2.1.0" @@ -205,30 +305,30 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "python-dateutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943 } +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034 }, + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, ] [[package]] name = "griffe" -version = "1.14.0" +version = "1.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ec/d7/6c09dd7ce4c7837e4cdb11dce980cb45ae3cd87677298dc3b781b6bce7d3/griffe-1.14.0.tar.gz", hash = "sha256:9d2a15c1eca966d68e00517de5d69dd1bc5c9f2335ef6c1775362ba5b8651a13", size = 424684 } +sdist = { url = "https://files.pythonhosted.org/packages/0d/0c/3a471b6e31951dce2360477420d0a8d1e00dea6cf33b70f3e8c3ab6e28e1/griffe-1.15.0.tar.gz", hash = "sha256:7726e3afd6f298fbc3696e67958803e7ac843c1cfe59734b6251a40cdbfb5eea", size = 424112, upload-time = "2025-11-10T15:03:15.52Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/b1/9ff6578d789a89812ff21e4e0f80ffae20a65d5dd84e7a17873fe3b365be/griffe-1.14.0-py3-none-any.whl", hash = "sha256:0e9d52832cccf0f7188cfe585ba962d2674b241c01916d780925df34873bceb0", size = 144439 }, + { url = "https://files.pythonhosted.org/packages/9c/83/3b1d03d36f224edded98e9affd0467630fc09d766c0e56fb1498cbb04a9b/griffe-1.15.0-py3-none-any.whl", hash = "sha256:6f6762661949411031f5fcda9593f586e6ce8340f0ba88921a0f2ef7a81eb9a3", size = 150705, upload-time = "2025-11-10T15:03:13.549Z" }, ] [[package]] name = "h11" version = "0.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] [[package]] @@ -239,9 +339,9 @@ dependencies = [ { name = "certifi" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] [[package]] @@ -254,27 +354,27 @@ dependencies = [ { name = "httpcore" }, { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] [[package]] name = "idna" version = "3.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582 } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008 }, + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] [[package]] name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] @@ -284,90 +384,112 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 } +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] [[package]] name = "markdown" -version = "3.9" +version = "3.10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8d/37/02347f6d6d8279247a5837082ebc26fc0d5aaeaf75aa013fcbb433c777ab/markdown-3.9.tar.gz", hash = "sha256:d2900fe1782bd33bdbbd56859defef70c2e78fc46668f8eb9df3128138f2cb6a", size = 364585 } +sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/7dd27d9d863b3376fcf23a5a13cb5d024aed1db46f963f1b5735ae43b3be/markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e", size = 364931, upload-time = "2025-11-03T19:51:15.007Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/70/ae/44c4a6a4cbb496d93c6257954260fe3a6e91b7bed2240e5dad2a717f5111/markdown-3.9-py3-none-any.whl", hash = "sha256:9f4d91ed810864ea88a6f32c07ba8bee1346c0cc1f6b1f9f6c822f2a9667d280", size = 107441 }, + { url = "https://files.pythonhosted.org/packages/70/81/54e3ce63502cd085a0c556652a4e1b919c45a446bd1e5300e10c44c8c521/markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c", size = 107678, upload-time = "2025-11-03T19:51:13.887Z" }, ] [[package]] name = "markupsafe" version = "3.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615 }, - { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020 }, - { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332 }, - { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947 }, - { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962 }, - { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760 }, - { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529 }, - { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015 }, - { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540 }, - { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105 }, - { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906 }, - { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622 }, - { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029 }, - { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374 }, - { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980 }, - { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990 }, - { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784 }, - { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588 }, - { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041 }, - { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543 }, - { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113 }, - { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911 }, - { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658 }, - { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066 }, - { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639 }, - { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569 }, - { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284 }, - { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801 }, - { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769 }, - { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642 }, - { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612 }, - { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200 }, - { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973 }, - { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619 }, - { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029 }, - { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408 }, - { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005 }, - { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048 }, - { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821 }, - { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606 }, - { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043 }, - { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747 }, - { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341 }, - { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073 }, - { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661 }, - { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069 }, - { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670 }, - { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598 }, - { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261 }, - { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835 }, - { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733 }, - { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672 }, - { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819 }, - { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426 }, - { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146 }, +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] [[package]] name = "mergedeep" version = "1.3.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661 } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354 }, + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, ] [[package]] @@ -389,9 +511,9 @@ dependencies = [ { name = "pyyaml-env-tag" }, { name = "watchdog" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159 } +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451 }, + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, ] [[package]] @@ -403,9 +525,9 @@ dependencies = [ { name = "markupsafe" }, { name = "mkdocs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/51/fa/9124cd63d822e2bcbea1450ae68cdc3faf3655c69b455f3a7ed36ce6c628/mkdocs_autorefs-1.4.3.tar.gz", hash = "sha256:beee715b254455c4aa93b6ef3c67579c399ca092259cc41b7d9342573ff1fc75", size = 55425 } +sdist = { url = "https://files.pythonhosted.org/packages/51/fa/9124cd63d822e2bcbea1450ae68cdc3faf3655c69b455f3a7ed36ce6c628/mkdocs_autorefs-1.4.3.tar.gz", hash = "sha256:beee715b254455c4aa93b6ef3c67579c399ca092259cc41b7d9342573ff1fc75", size = 55425, upload-time = "2025-08-26T14:23:17.223Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/4d/7123b6fa2278000688ebd338e2a06d16870aaf9eceae6ba047ea05f92df1/mkdocs_autorefs-1.4.3-py3-none-any.whl", hash = "sha256:469d85eb3114801d08e9cc55d102b3ba65917a869b893403b8987b601cf55dc9", size = 25034 }, + { url = "https://files.pythonhosted.org/packages/9f/4d/7123b6fa2278000688ebd338e2a06d16870aaf9eceae6ba047ea05f92df1/mkdocs_autorefs-1.4.3-py3-none-any.whl", hash = "sha256:469d85eb3114801d08e9cc55d102b3ba65917a869b893403b8987b601cf55dc9", size = 25034, upload-time = "2025-08-26T14:23:15.906Z" }, ] [[package]] @@ -417,14 +539,14 @@ dependencies = [ { name = "platformdirs" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239 } +sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239, upload-time = "2023-11-20T17:51:09.981Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521 }, + { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521, upload-time = "2023-11-20T17:51:08.587Z" }, ] [[package]] name = "mkdocs-material" -version = "9.6.22" +version = "9.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "babel" }, @@ -439,18 +561,18 @@ dependencies = [ { name = "pymdown-extensions" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5f/5d/317e37b6c43325cb376a1d6439df9cc743b8ee41c84603c2faf7286afc82/mkdocs_material-9.6.22.tar.gz", hash = "sha256:87c158b0642e1ada6da0cbd798a3389b0bc5516b90e5ece4a0fb939f00bacd1c", size = 4044968 } +sdist = { url = "https://files.pythonhosted.org/packages/9c/3b/111b84cd6ff28d9e955b5f799ef217a17bc1684ac346af333e6100e413cb/mkdocs_material-9.7.0.tar.gz", hash = "sha256:602b359844e906ee402b7ed9640340cf8a474420d02d8891451733b6b02314ec", size = 4094546, upload-time = "2025-11-11T08:49:09.73Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/82/6fdb9a7a04fb222f4849ffec1006f891a0280825a20314d11f3ccdee14eb/mkdocs_material-9.6.22-py3-none-any.whl", hash = "sha256:14ac5f72d38898b2f98ac75a5531aaca9366eaa427b0f49fc2ecf04d99b7ad84", size = 9206252 }, + { url = "https://files.pythonhosted.org/packages/04/87/eefe8d5e764f4cf50ed91b943f8e8f96b5efd65489d8303b7a36e2e79834/mkdocs_material-9.7.0-py3-none-any.whl", hash = "sha256:da2866ea53601125ff5baa8aa06404c6e07af3c5ce3d5de95e3b52b80b442887", size = 9283770, upload-time = "2025-11-11T08:49:06.26Z" }, ] [[package]] name = "mkdocs-material-extensions" version = "1.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847 } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728 }, + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, ] [[package]] @@ -465,9 +587,9 @@ dependencies = [ { name = "mkdocs-autorefs" }, { name = "pymdown-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c5/33/2fa3243439f794e685d3e694590d28469a9b8ea733af4b48c250a3ffc9a0/mkdocstrings-0.30.1.tar.gz", hash = "sha256:84a007aae9b707fb0aebfc9da23db4b26fc9ab562eb56e335e9ec480cb19744f", size = 106350 } +sdist = { url = "https://files.pythonhosted.org/packages/c5/33/2fa3243439f794e685d3e694590d28469a9b8ea733af4b48c250a3ffc9a0/mkdocstrings-0.30.1.tar.gz", hash = "sha256:84a007aae9b707fb0aebfc9da23db4b26fc9ab562eb56e335e9ec480cb19744f", size = 106350, upload-time = "2025-09-19T10:49:26.446Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/2c/f0dc4e1ee7f618f5bff7e05898d20bf8b6e7fa612038f768bfa295f136a4/mkdocstrings-0.30.1-py3-none-any.whl", hash = "sha256:41bd71f284ca4d44a668816193e4025c950b002252081e387433656ae9a70a82", size = 36704 }, + { url = "https://files.pythonhosted.org/packages/7b/2c/f0dc4e1ee7f618f5bff7e05898d20bf8b6e7fa612038f768bfa295f136a4/mkdocstrings-0.30.1-py3-none-any.whl", hash = "sha256:41bd71f284ca4d44a668816193e4025c950b002252081e387433656ae9a70a82", size = 36704, upload-time = "2025-09-19T10:49:24.805Z" }, ] [package.optional-dependencies] @@ -477,16 +599,17 @@ python = [ [[package]] name = "mkdocstrings-python" -version = "1.18.2" +version = "1.19.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "griffe" }, { name = "mkdocs-autorefs" }, { name = "mkdocstrings" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/95/ae/58ab2bfbee2792e92a98b97e872f7c003deb903071f75d8d83aa55db28fa/mkdocstrings_python-1.18.2.tar.gz", hash = "sha256:4ad536920a07b6336f50d4c6d5603316fafb1172c5c882370cbbc954770ad323", size = 207972 } +sdist = { url = "https://files.pythonhosted.org/packages/75/1c/3af8413919b0839b96a78f60e8bd0dfd26c844d3717eeb77f80b43f5be1c/mkdocstrings_python-1.19.0.tar.gz", hash = "sha256:917aac66cf121243c11db5b89f66b0ded6c53ec0de5318ff5e22424eb2f2e57c", size = 204010, upload-time = "2025-11-10T13:30:55.915Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/8f/ce008599d9adebf33ed144e7736914385e8537f5fc686fdb7cceb8c22431/mkdocstrings_python-1.18.2-py3-none-any.whl", hash = "sha256:944fe6deb8f08f33fa936d538233c4036e9f53e840994f6146e8e94eb71b600d", size = 138215 }, + { url = "https://files.pythonhosted.org/packages/98/5c/2597cef67b6947b15c47f8dba967a0baf19fbdfdc86f6e4a8ba7af8b581a/mkdocstrings_python-1.19.0-py3-none-any.whl", hash = "sha256:395c1032af8f005234170575cc0c5d4d20980846623b623b35594281be4a3059", size = 143417, upload-time = "2025-11-10T13:30:54.164Z" }, ] [[package]] @@ -496,119 +619,219 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mypy-extensions" }, { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/07/06/dfdd2bc60c66611dd8335f463818514733bc763e4760dee289dcc33df709/mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34", size = 12908273 }, - { url = "https://files.pythonhosted.org/packages/81/14/6a9de6d13a122d5608e1a04130724caf9170333ac5a924e10f670687d3eb/mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764", size = 11920910 }, - { url = "https://files.pythonhosted.org/packages/5f/a9/b29de53e42f18e8cc547e38daa9dfa132ffdc64f7250e353f5c8cdd44bee/mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893", size = 12465585 }, - { url = "https://files.pythonhosted.org/packages/77/ae/6c3d2c7c61ff21f2bee938c917616c92ebf852f015fb55917fd6e2811db2/mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914", size = 13348562 }, - { url = "https://files.pythonhosted.org/packages/4d/31/aec68ab3b4aebdf8f36d191b0685d99faa899ab990753ca0fee60fb99511/mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8", size = 13533296 }, - { url = "https://files.pythonhosted.org/packages/9f/83/abcb3ad9478fca3ebeb6a5358bb0b22c95ea42b43b7789c7fb1297ca44f4/mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074", size = 9828828 }, - { url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728 }, - { url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758 }, - { url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342 }, - { url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709 }, - { url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806 }, - { url = "https://files.pythonhosted.org/packages/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262 }, - { url = "https://files.pythonhosted.org/packages/5a/0c/7d5300883da16f0063ae53996358758b2a2df2a09c72a5061fa79a1f5006/mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce", size = 12893775 }, - { url = "https://files.pythonhosted.org/packages/50/df/2cffbf25737bdb236f60c973edf62e3e7b4ee1c25b6878629e88e2cde967/mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d", size = 11936852 }, - { url = "https://files.pythonhosted.org/packages/be/50/34059de13dd269227fb4a03be1faee6e2a4b04a2051c82ac0a0b5a773c9a/mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c", size = 12480242 }, - { url = "https://files.pythonhosted.org/packages/5b/11/040983fad5132d85914c874a2836252bbc57832065548885b5bb5b0d4359/mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb", size = 13326683 }, - { url = "https://files.pythonhosted.org/packages/e9/ba/89b2901dd77414dd7a8c8729985832a5735053be15b744c18e4586e506ef/mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075", size = 13514749 }, - { url = "https://files.pythonhosted.org/packages/25/bc/cc98767cffd6b2928ba680f3e5bc969c4152bf7c2d83f92f5a504b92b0eb/mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf", size = 9982959 }, - { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367 }, +sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload-time = "2025-09-19T00:11:10.519Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/6f/657961a0743cff32e6c0611b63ff1c1970a0b482ace35b069203bf705187/mypy-1.18.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c1eab0cf6294dafe397c261a75f96dc2c31bffe3b944faa24db5def4e2b0f77c", size = 12807973, upload-time = "2025-09-19T00:10:35.282Z" }, + { url = "https://files.pythonhosted.org/packages/10/e9/420822d4f661f13ca8900f5fa239b40ee3be8b62b32f3357df9a3045a08b/mypy-1.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7a780ca61fc239e4865968ebc5240bb3bf610ef59ac398de9a7421b54e4a207e", size = 11896527, upload-time = "2025-09-19T00:10:55.791Z" }, + { url = "https://files.pythonhosted.org/packages/aa/73/a05b2bbaa7005f4642fcfe40fb73f2b4fb6bb44229bd585b5878e9a87ef8/mypy-1.18.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:448acd386266989ef11662ce3c8011fd2a7b632e0ec7d61a98edd8e27472225b", size = 12507004, upload-time = "2025-09-19T00:11:05.411Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/f6e4b9f0d031c11ccbd6f17da26564f3a0f3c4155af344006434b0a05a9d/mypy-1.18.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9e171c465ad3901dc652643ee4bffa8e9fef4d7d0eece23b428908c77a76a66", size = 13245947, upload-time = "2025-09-19T00:10:46.923Z" }, + { url = "https://files.pythonhosted.org/packages/d7/97/19727e7499bfa1ae0773d06afd30ac66a58ed7437d940c70548634b24185/mypy-1.18.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:592ec214750bc00741af1f80cbf96b5013d81486b7bb24cb052382c19e40b428", size = 13499217, upload-time = "2025-09-19T00:09:39.472Z" }, + { url = "https://files.pythonhosted.org/packages/9f/4f/90dc8c15c1441bf31cf0f9918bb077e452618708199e530f4cbd5cede6ff/mypy-1.18.2-cp310-cp310-win_amd64.whl", hash = "sha256:7fb95f97199ea11769ebe3638c29b550b5221e997c63b14ef93d2e971606ebed", size = 9766753, upload-time = "2025-09-19T00:10:49.161Z" }, + { url = "https://files.pythonhosted.org/packages/88/87/cafd3ae563f88f94eec33f35ff722d043e09832ea8530ef149ec1efbaf08/mypy-1.18.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:807d9315ab9d464125aa9fcf6d84fde6e1dc67da0b6f80e7405506b8ac72bc7f", size = 12731198, upload-time = "2025-09-19T00:09:44.857Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e0/1e96c3d4266a06d4b0197ace5356d67d937d8358e2ee3ffac71faa843724/mypy-1.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:776bb00de1778caf4db739c6e83919c1d85a448f71979b6a0edd774ea8399341", size = 11817879, upload-time = "2025-09-19T00:09:47.131Z" }, + { url = "https://files.pythonhosted.org/packages/72/ef/0c9ba89eb03453e76bdac5a78b08260a848c7bfc5d6603634774d9cd9525/mypy-1.18.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1379451880512ffce14505493bd9fe469e0697543717298242574882cf8cdb8d", size = 12427292, upload-time = "2025-09-19T00:10:22.472Z" }, + { url = "https://files.pythonhosted.org/packages/1a/52/ec4a061dd599eb8179d5411d99775bec2a20542505988f40fc2fee781068/mypy-1.18.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1331eb7fd110d60c24999893320967594ff84c38ac6d19e0a76c5fd809a84c86", size = 13163750, upload-time = "2025-09-19T00:09:51.472Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5f/2cf2ceb3b36372d51568f2208c021870fe7834cf3186b653ac6446511839/mypy-1.18.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ca30b50a51e7ba93b00422e486cbb124f1c56a535e20eff7b2d6ab72b3b2e37", size = 13351827, upload-time = "2025-09-19T00:09:58.311Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7d/2697b930179e7277529eaaec1513f8de622818696857f689e4a5432e5e27/mypy-1.18.2-cp311-cp311-win_amd64.whl", hash = "sha256:664dc726e67fa54e14536f6e1224bcfce1d9e5ac02426d2326e2bb4e081d1ce8", size = 9757983, upload-time = "2025-09-19T00:10:09.071Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/dfdd2bc60c66611dd8335f463818514733bc763e4760dee289dcc33df709/mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34", size = 12908273, upload-time = "2025-09-19T00:10:58.321Z" }, + { url = "https://files.pythonhosted.org/packages/81/14/6a9de6d13a122d5608e1a04130724caf9170333ac5a924e10f670687d3eb/mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764", size = 11920910, upload-time = "2025-09-19T00:10:20.043Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a9/b29de53e42f18e8cc547e38daa9dfa132ffdc64f7250e353f5c8cdd44bee/mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893", size = 12465585, upload-time = "2025-09-19T00:10:33.005Z" }, + { url = "https://files.pythonhosted.org/packages/77/ae/6c3d2c7c61ff21f2bee938c917616c92ebf852f015fb55917fd6e2811db2/mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914", size = 13348562, upload-time = "2025-09-19T00:10:11.51Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/aec68ab3b4aebdf8f36d191b0685d99faa899ab990753ca0fee60fb99511/mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8", size = 13533296, upload-time = "2025-09-19T00:10:06.568Z" }, + { url = "https://files.pythonhosted.org/packages/9f/83/abcb3ad9478fca3ebeb6a5358bb0b22c95ea42b43b7789c7fb1297ca44f4/mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074", size = 9828828, upload-time = "2025-09-19T00:10:28.203Z" }, + { url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728, upload-time = "2025-09-19T00:10:01.33Z" }, + { url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758, upload-time = "2025-09-19T00:10:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342, upload-time = "2025-09-19T00:11:00.371Z" }, + { url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709, upload-time = "2025-09-19T00:11:03.358Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806, upload-time = "2025-09-19T00:10:26.073Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262, upload-time = "2025-09-19T00:10:40.035Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0c/7d5300883da16f0063ae53996358758b2a2df2a09c72a5061fa79a1f5006/mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce", size = 12893775, upload-time = "2025-09-19T00:10:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/50/df/2cffbf25737bdb236f60c973edf62e3e7b4ee1c25b6878629e88e2cde967/mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d", size = 11936852, upload-time = "2025-09-19T00:10:51.631Z" }, + { url = "https://files.pythonhosted.org/packages/be/50/34059de13dd269227fb4a03be1faee6e2a4b04a2051c82ac0a0b5a773c9a/mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c", size = 12480242, upload-time = "2025-09-19T00:11:07.955Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/040983fad5132d85914c874a2836252bbc57832065548885b5bb5b0d4359/mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb", size = 13326683, upload-time = "2025-09-19T00:09:55.572Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ba/89b2901dd77414dd7a8c8729985832a5735053be15b744c18e4586e506ef/mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075", size = 13514749, upload-time = "2025-09-19T00:10:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/25/bc/cc98767cffd6b2928ba680f3e5bc969c4152bf7c2d83f92f5a504b92b0eb/mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf", size = 9982959, upload-time = "2025-09-19T00:10:37.344Z" }, + { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload-time = "2025-09-19T00:10:15.489Z" }, ] [[package]] name = "mypy-extensions" version = "1.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343 } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 }, + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, ] [[package]] name = "numpy" version = "2.3.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/f4/098d2270d52b41f1bd7db9fc288aaa0400cb48c2a3e2af6fa365d9720947/numpy-2.3.4.tar.gz", hash = "sha256:a7d018bfedb375a8d979ac758b120ba846a7fe764911a64465fd87b8729f4a6a", size = 20582187 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/96/7a/02420400b736f84317e759291b8edaeee9dc921f72b045475a9cbdb26b17/numpy-2.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef1b5a3e808bc40827b5fa2c8196151a4c5abe110e1726949d7abddfe5c7ae11", size = 20957727 }, - { url = "https://files.pythonhosted.org/packages/18/90/a014805d627aa5750f6f0e878172afb6454552da929144b3c07fcae1bb13/numpy-2.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c2f91f496a87235c6aaf6d3f3d89b17dba64996abadccb289f48456cff931ca9", size = 14187262 }, - { url = "https://files.pythonhosted.org/packages/c7/e4/0a94b09abe89e500dc748e7515f21a13e30c5c3fe3396e6d4ac108c25fca/numpy-2.3.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:f77e5b3d3da652b474cc80a14084927a5e86a5eccf54ca8ca5cbd697bf7f2667", size = 5115992 }, - { url = "https://files.pythonhosted.org/packages/88/dd/db77c75b055c6157cbd4f9c92c4458daef0dd9cbe6d8d2fe7f803cb64c37/numpy-2.3.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:8ab1c5f5ee40d6e01cbe96de5863e39b215a4d24e7d007cad56c7184fdf4aeef", size = 6648672 }, - { url = "https://files.pythonhosted.org/packages/e1/e6/e31b0d713719610e406c0ea3ae0d90760465b086da8783e2fd835ad59027/numpy-2.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77b84453f3adcb994ddbd0d1c5d11db2d6bda1a2b7fd5ac5bd4649d6f5dc682e", size = 14284156 }, - { url = "https://files.pythonhosted.org/packages/f9/58/30a85127bfee6f108282107caf8e06a1f0cc997cb6b52cdee699276fcce4/numpy-2.3.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4121c5beb58a7f9e6dfdee612cb24f4df5cd4db6e8261d7f4d7450a997a65d6a", size = 16641271 }, - { url = "https://files.pythonhosted.org/packages/06/f2/2e06a0f2adf23e3ae29283ad96959267938d0efd20a2e25353b70065bfec/numpy-2.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:65611ecbb00ac9846efe04db15cbe6186f562f6bb7e5e05f077e53a599225d16", size = 16059531 }, - { url = "https://files.pythonhosted.org/packages/b0/e7/b106253c7c0d5dc352b9c8fab91afd76a93950998167fa3e5afe4ef3a18f/numpy-2.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dabc42f9c6577bcc13001b8810d300fe814b4cfbe8a92c873f269484594f9786", size = 18578983 }, - { url = "https://files.pythonhosted.org/packages/73/e3/04ecc41e71462276ee867ccbef26a4448638eadecf1bc56772c9ed6d0255/numpy-2.3.4-cp312-cp312-win32.whl", hash = "sha256:a49d797192a8d950ca59ee2d0337a4d804f713bb5c3c50e8db26d49666e351dc", size = 6291380 }, - { url = "https://files.pythonhosted.org/packages/3d/a8/566578b10d8d0e9955b1b6cd5db4e9d4592dd0026a941ff7994cedda030a/numpy-2.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:985f1e46358f06c2a09921e8921e2c98168ed4ae12ccd6e5e87a4f1857923f32", size = 12787999 }, - { url = "https://files.pythonhosted.org/packages/58/22/9c903a957d0a8071b607f5b1bff0761d6e608b9a965945411f867d515db1/numpy-2.3.4-cp312-cp312-win_arm64.whl", hash = "sha256:4635239814149e06e2cb9db3dd584b2fa64316c96f10656983b8026a82e6e4db", size = 10197412 }, - { url = "https://files.pythonhosted.org/packages/57/7e/b72610cc91edf138bc588df5150957a4937221ca6058b825b4725c27be62/numpy-2.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c090d4860032b857d94144d1a9976b8e36709e40386db289aaf6672de2a81966", size = 20950335 }, - { url = "https://files.pythonhosted.org/packages/3e/46/bdd3370dcea2f95ef14af79dbf81e6927102ddf1cc54adc0024d61252fd9/numpy-2.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a13fc473b6db0be619e45f11f9e81260f7302f8d180c49a22b6e6120022596b3", size = 14179878 }, - { url = "https://files.pythonhosted.org/packages/ac/01/5a67cb785bda60f45415d09c2bc245433f1c68dd82eef9c9002c508b5a65/numpy-2.3.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:3634093d0b428e6c32c3a69b78e554f0cd20ee420dcad5a9f3b2a63762ce4197", size = 5108673 }, - { url = "https://files.pythonhosted.org/packages/c2/cd/8428e23a9fcebd33988f4cb61208fda832800ca03781f471f3727a820704/numpy-2.3.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:043885b4f7e6e232d7df4f51ffdef8c36320ee9d5f227b380ea636722c7ed12e", size = 6641438 }, - { url = "https://files.pythonhosted.org/packages/3e/d1/913fe563820f3c6b079f992458f7331278dcd7ba8427e8e745af37ddb44f/numpy-2.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ee6a571d1e4f0ea6d5f22d6e5fbd6ed1dc2b18542848e1e7301bd190500c9d7", size = 14281290 }, - { url = "https://files.pythonhosted.org/packages/9e/7e/7d306ff7cb143e6d975cfa7eb98a93e73495c4deabb7d1b5ecf09ea0fd69/numpy-2.3.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc8a63918b04b8571789688b2780ab2b4a33ab44bfe8ccea36d3eba51228c953", size = 16636543 }, - { url = "https://files.pythonhosted.org/packages/47/6a/8cfc486237e56ccfb0db234945552a557ca266f022d281a2f577b98e955c/numpy-2.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:40cc556d5abbc54aabe2b1ae287042d7bdb80c08edede19f0c0afb36ae586f37", size = 16056117 }, - { url = "https://files.pythonhosted.org/packages/b1/0e/42cb5e69ea901e06ce24bfcc4b5664a56f950a70efdcf221f30d9615f3f3/numpy-2.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ecb63014bb7f4ce653f8be7f1df8cbc6093a5a2811211770f6606cc92b5a78fd", size = 18577788 }, - { url = "https://files.pythonhosted.org/packages/86/92/41c3d5157d3177559ef0a35da50f0cda7fa071f4ba2306dd36818591a5bc/numpy-2.3.4-cp313-cp313-win32.whl", hash = "sha256:e8370eb6925bb8c1c4264fec52b0384b44f675f191df91cbe0140ec9f0955646", size = 6282620 }, - { url = "https://files.pythonhosted.org/packages/09/97/fd421e8bc50766665ad35536c2bb4ef916533ba1fdd053a62d96cc7c8b95/numpy-2.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:56209416e81a7893036eea03abcb91c130643eb14233b2515c90dcac963fe99d", size = 12784672 }, - { url = "https://files.pythonhosted.org/packages/ad/df/5474fb2f74970ca8eb978093969b125a84cc3d30e47f82191f981f13a8a0/numpy-2.3.4-cp313-cp313-win_arm64.whl", hash = "sha256:a700a4031bc0fd6936e78a752eefb79092cecad2599ea9c8039c548bc097f9bc", size = 10196702 }, - { url = "https://files.pythonhosted.org/packages/11/83/66ac031464ec1767ea3ed48ce40f615eb441072945e98693bec0bcd056cc/numpy-2.3.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:86966db35c4040fdca64f0816a1c1dd8dbd027d90fca5a57e00e1ca4cd41b879", size = 21049003 }, - { url = "https://files.pythonhosted.org/packages/5f/99/5b14e0e686e61371659a1d5bebd04596b1d72227ce36eed121bb0aeab798/numpy-2.3.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:838f045478638b26c375ee96ea89464d38428c69170360b23a1a50fa4baa3562", size = 14302980 }, - { url = "https://files.pythonhosted.org/packages/2c/44/e9486649cd087d9fc6920e3fc3ac2aba10838d10804b1e179fb7cbc4e634/numpy-2.3.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d7315ed1dab0286adca467377c8381cd748f3dc92235f22a7dfc42745644a96a", size = 5231472 }, - { url = "https://files.pythonhosted.org/packages/3e/51/902b24fa8887e5fe2063fd61b1895a476d0bbf46811ab0c7fdf4bd127345/numpy-2.3.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:84f01a4d18b2cc4ade1814a08e5f3c907b079c847051d720fad15ce37aa930b6", size = 6739342 }, - { url = "https://files.pythonhosted.org/packages/34/f1/4de9586d05b1962acdcdb1dc4af6646361a643f8c864cef7c852bf509740/numpy-2.3.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:817e719a868f0dacde4abdfc5c1910b301877970195db9ab6a5e2c4bd5b121f7", size = 14354338 }, - { url = "https://files.pythonhosted.org/packages/1f/06/1c16103b425de7969d5a76bdf5ada0804b476fed05d5f9e17b777f1cbefd/numpy-2.3.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85e071da78d92a214212cacea81c6da557cab307f2c34b5f85b628e94803f9c0", size = 16702392 }, - { url = "https://files.pythonhosted.org/packages/34/b2/65f4dc1b89b5322093572b6e55161bb42e3e0487067af73627f795cc9d47/numpy-2.3.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2ec646892819370cf3558f518797f16597b4e4669894a2ba712caccc9da53f1f", size = 16134998 }, - { url = "https://files.pythonhosted.org/packages/d4/11/94ec578896cdb973aaf56425d6c7f2aff4186a5c00fac15ff2ec46998b46/numpy-2.3.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:035796aaaddfe2f9664b9a9372f089cfc88bd795a67bd1bfe15e6e770934cf64", size = 18651574 }, - { url = "https://files.pythonhosted.org/packages/62/b7/7efa763ab33dbccf56dade36938a77345ce8e8192d6b39e470ca25ff3cd0/numpy-2.3.4-cp313-cp313t-win32.whl", hash = "sha256:fea80f4f4cf83b54c3a051f2f727870ee51e22f0248d3114b8e755d160b38cfb", size = 6413135 }, - { url = "https://files.pythonhosted.org/packages/43/70/aba4c38e8400abcc2f345e13d972fb36c26409b3e644366db7649015f291/numpy-2.3.4-cp313-cp313t-win_amd64.whl", hash = "sha256:15eea9f306b98e0be91eb344a94c0e630689ef302e10c2ce5f7e11905c704f9c", size = 12928582 }, - { url = "https://files.pythonhosted.org/packages/67/63/871fad5f0073fc00fbbdd7232962ea1ac40eeaae2bba66c76214f7954236/numpy-2.3.4-cp313-cp313t-win_arm64.whl", hash = "sha256:b6c231c9c2fadbae4011ca5e7e83e12dc4a5072f1a1d85a0a7b3ed754d145a40", size = 10266691 }, - { url = "https://files.pythonhosted.org/packages/72/71/ae6170143c115732470ae3a2d01512870dd16e0953f8a6dc89525696069b/numpy-2.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:81c3e6d8c97295a7360d367f9f8553973651b76907988bb6066376bc2252f24e", size = 20955580 }, - { url = "https://files.pythonhosted.org/packages/af/39/4be9222ffd6ca8a30eda033d5f753276a9c3426c397bb137d8e19dedd200/numpy-2.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7c26b0b2bf58009ed1f38a641f3db4be8d960a417ca96d14e5b06df1506d41ff", size = 14188056 }, - { url = "https://files.pythonhosted.org/packages/6c/3d/d85f6700d0a4aa4f9491030e1021c2b2b7421b2b38d01acd16734a2bfdc7/numpy-2.3.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:62b2198c438058a20b6704351b35a1d7db881812d8512d67a69c9de1f18ca05f", size = 5116555 }, - { url = "https://files.pythonhosted.org/packages/bf/04/82c1467d86f47eee8a19a464c92f90a9bb68ccf14a54c5224d7031241ffb/numpy-2.3.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:9d729d60f8d53a7361707f4b68a9663c968882dd4f09e0d58c044c8bf5faee7b", size = 6643581 }, - { url = "https://files.pythonhosted.org/packages/0c/d3/c79841741b837e293f48bd7db89d0ac7a4f2503b382b78a790ef1dc778a5/numpy-2.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd0c630cf256b0a7fd9d0a11c9413b42fef5101219ce6ed5a09624f5a65392c7", size = 14299186 }, - { url = "https://files.pythonhosted.org/packages/e8/7e/4a14a769741fbf237eec5a12a2cbc7a4c4e061852b6533bcb9e9a796c908/numpy-2.3.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5e081bc082825f8b139f9e9fe42942cb4054524598aaeb177ff476cc76d09d2", size = 16638601 }, - { url = "https://files.pythonhosted.org/packages/93/87/1c1de269f002ff0a41173fe01dcc925f4ecff59264cd8f96cf3b60d12c9b/numpy-2.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15fb27364ed84114438fff8aaf998c9e19adbeba08c0b75409f8c452a8692c52", size = 16074219 }, - { url = "https://files.pythonhosted.org/packages/cd/28/18f72ee77408e40a76d691001ae599e712ca2a47ddd2c4f695b16c65f077/numpy-2.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:85d9fb2d8cd998c84d13a79a09cc0c1091648e848e4e6249b0ccd7f6b487fa26", size = 18576702 }, - { url = "https://files.pythonhosted.org/packages/c3/76/95650169b465ececa8cf4b2e8f6df255d4bf662775e797ade2025cc51ae6/numpy-2.3.4-cp314-cp314-win32.whl", hash = "sha256:e73d63fd04e3a9d6bc187f5455d81abfad05660b212c8804bf3b407e984cd2bc", size = 6337136 }, - { url = "https://files.pythonhosted.org/packages/dc/89/a231a5c43ede5d6f77ba4a91e915a87dea4aeea76560ba4d2bf185c683f0/numpy-2.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:3da3491cee49cf16157e70f607c03a217ea6647b1cea4819c4f48e53d49139b9", size = 12920542 }, - { url = "https://files.pythonhosted.org/packages/0d/0c/ae9434a888f717c5ed2ff2393b3f344f0ff6f1c793519fa0c540461dc530/numpy-2.3.4-cp314-cp314-win_arm64.whl", hash = "sha256:6d9cd732068e8288dbe2717177320723ccec4fb064123f0caf9bbd90ab5be868", size = 10480213 }, - { url = "https://files.pythonhosted.org/packages/83/4b/c4a5f0841f92536f6b9592694a5b5f68c9ab37b775ff342649eadf9055d3/numpy-2.3.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:22758999b256b595cf0b1d102b133bb61866ba5ceecf15f759623b64c020c9ec", size = 21052280 }, - { url = "https://files.pythonhosted.org/packages/3e/80/90308845fc93b984d2cc96d83e2324ce8ad1fd6efea81b324cba4b673854/numpy-2.3.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9cb177bc55b010b19798dc5497d540dea67fd13a8d9e882b2dae71de0cf09eb3", size = 14302930 }, - { url = "https://files.pythonhosted.org/packages/3d/4e/07439f22f2a3b247cec4d63a713faae55e1141a36e77fb212881f7cda3fb/numpy-2.3.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0f2bcc76f1e05e5ab58893407c63d90b2029908fa41f9f1cc51eecce936c3365", size = 5231504 }, - { url = "https://files.pythonhosted.org/packages/ab/de/1e11f2547e2fe3d00482b19721855348b94ada8359aef5d40dd57bfae9df/numpy-2.3.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8dc20bde86802df2ed8397a08d793da0ad7a5fd4ea3ac85d757bf5dd4ad7c252", size = 6739405 }, - { url = "https://files.pythonhosted.org/packages/3b/40/8cd57393a26cebe2e923005db5134a946c62fa56a1087dc7c478f3e30837/numpy-2.3.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e199c087e2aa71c8f9ce1cb7a8e10677dc12457e7cc1be4798632da37c3e86e", size = 14354866 }, - { url = "https://files.pythonhosted.org/packages/93/39/5b3510f023f96874ee6fea2e40dfa99313a00bf3ab779f3c92978f34aace/numpy-2.3.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85597b2d25ddf655495e2363fe044b0ae999b75bc4d630dc0d886484b03a5eb0", size = 16703296 }, - { url = "https://files.pythonhosted.org/packages/41/0d/19bb163617c8045209c1996c4e427bccbc4bbff1e2c711f39203c8ddbb4a/numpy-2.3.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:04a69abe45b49c5955923cf2c407843d1c85013b424ae8a560bba16c92fe44a0", size = 16136046 }, - { url = "https://files.pythonhosted.org/packages/e2/c1/6dba12fdf68b02a21ac411c9df19afa66bed2540f467150ca64d246b463d/numpy-2.3.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e1708fac43ef8b419c975926ce1eaf793b0c13b7356cfab6ab0dc34c0a02ac0f", size = 18652691 }, - { url = "https://files.pythonhosted.org/packages/f8/73/f85056701dbbbb910c51d846c58d29fd46b30eecd2b6ba760fc8b8a1641b/numpy-2.3.4-cp314-cp314t-win32.whl", hash = "sha256:863e3b5f4d9915aaf1b8ec79ae560ad21f0b8d5e3adc31e73126491bb86dee1d", size = 6485782 }, - { url = "https://files.pythonhosted.org/packages/17/90/28fa6f9865181cb817c2471ee65678afa8a7e2a1fb16141473d5fa6bacc3/numpy-2.3.4-cp314-cp314t-win_amd64.whl", hash = "sha256:962064de37b9aef801d33bc579690f8bfe6c5e70e29b61783f60bcba838a14d6", size = 13113301 }, - { url = "https://files.pythonhosted.org/packages/54/23/08c002201a8e7e1f9afba93b97deceb813252d9cfd0d3351caed123dcf97/numpy-2.3.4-cp314-cp314t-win_arm64.whl", hash = "sha256:8b5a9a39c45d852b62693d9b3f3e0fe052541f804296ff401a72a1b60edafb29", size = 10547532 }, +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/f4/098d2270d52b41f1bd7db9fc288aaa0400cb48c2a3e2af6fa365d9720947/numpy-2.3.4.tar.gz", hash = "sha256:a7d018bfedb375a8d979ac758b120ba846a7fe764911a64465fd87b8729f4a6a", size = 20582187, upload-time = "2025-10-15T16:18:11.77Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/e7/0e07379944aa8afb49a556a2b54587b828eb41dc9adc56fb7615b678ca53/numpy-2.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e78aecd2800b32e8347ce49316d3eaf04aed849cd5b38e0af39f829a4e59f5eb", size = 21259519, upload-time = "2025-10-15T16:15:19.012Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cb/5a69293561e8819b09e34ed9e873b9a82b5f2ade23dce4c51dc507f6cfe1/numpy-2.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7fd09cc5d65bda1e79432859c40978010622112e9194e581e3415a3eccc7f43f", size = 14452796, upload-time = "2025-10-15T16:15:23.094Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/ff11611200acd602a1e5129e36cfd25bf01ad8e5cf927baf2e90236eb02e/numpy-2.3.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:1b219560ae2c1de48ead517d085bc2d05b9433f8e49d0955c82e8cd37bd7bf36", size = 5381639, upload-time = "2025-10-15T16:15:25.572Z" }, + { url = "https://files.pythonhosted.org/packages/ea/77/e95c757a6fe7a48d28a009267408e8aa382630cc1ad1db7451b3bc21dbb4/numpy-2.3.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:bafa7d87d4c99752d07815ed7a2c0964f8ab311eb8168f41b910bd01d15b6032", size = 6914296, upload-time = "2025-10-15T16:15:27.079Z" }, + { url = "https://files.pythonhosted.org/packages/a3/d2/137c7b6841c942124eae921279e5c41b1c34bab0e6fc60c7348e69afd165/numpy-2.3.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36dc13af226aeab72b7abad501d370d606326a0029b9f435eacb3b8c94b8a8b7", size = 14591904, upload-time = "2025-10-15T16:15:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/bb/32/67e3b0f07b0aba57a078c4ab777a9e8e6bc62f24fb53a2337f75f9691699/numpy-2.3.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7b2f9a18b5ff9824a6af80de4f37f4ec3c2aab05ef08f51c77a093f5b89adda", size = 16939602, upload-time = "2025-10-15T16:15:31.106Z" }, + { url = "https://files.pythonhosted.org/packages/95/22/9639c30e32c93c4cee3ccdb4b09c2d0fbff4dcd06d36b357da06146530fb/numpy-2.3.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9984bd645a8db6ca15d850ff996856d8762c51a2239225288f08f9050ca240a0", size = 16372661, upload-time = "2025-10-15T16:15:33.546Z" }, + { url = "https://files.pythonhosted.org/packages/12/e9/a685079529be2b0156ae0c11b13d6be647743095bb51d46589e95be88086/numpy-2.3.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:64c5825affc76942973a70acf438a8ab618dbd692b84cd5ec40a0a0509edc09a", size = 18884682, upload-time = "2025-10-15T16:15:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/cf/85/f6f00d019b0cc741e64b4e00ce865a57b6bed945d1bbeb1ccadbc647959b/numpy-2.3.4-cp311-cp311-win32.whl", hash = "sha256:ed759bf7a70342f7817d88376eb7142fab9fef8320d6019ef87fae05a99874e1", size = 6570076, upload-time = "2025-10-15T16:15:38.225Z" }, + { url = "https://files.pythonhosted.org/packages/7d/10/f8850982021cb90e2ec31990291f9e830ce7d94eef432b15066e7cbe0bec/numpy-2.3.4-cp311-cp311-win_amd64.whl", hash = "sha256:faba246fb30ea2a526c2e9645f61612341de1a83fb1e0c5edf4ddda5a9c10996", size = 13089358, upload-time = "2025-10-15T16:15:40.404Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ad/afdd8351385edf0b3445f9e24210a9c3971ef4de8fd85155462fc4321d79/numpy-2.3.4-cp311-cp311-win_arm64.whl", hash = "sha256:4c01835e718bcebe80394fd0ac66c07cbb90147ebbdad3dcecd3f25de2ae7e2c", size = 10462292, upload-time = "2025-10-15T16:15:42.896Z" }, + { url = "https://files.pythonhosted.org/packages/96/7a/02420400b736f84317e759291b8edaeee9dc921f72b045475a9cbdb26b17/numpy-2.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef1b5a3e808bc40827b5fa2c8196151a4c5abe110e1726949d7abddfe5c7ae11", size = 20957727, upload-time = "2025-10-15T16:15:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/18/90/a014805d627aa5750f6f0e878172afb6454552da929144b3c07fcae1bb13/numpy-2.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c2f91f496a87235c6aaf6d3f3d89b17dba64996abadccb289f48456cff931ca9", size = 14187262, upload-time = "2025-10-15T16:15:47.761Z" }, + { url = "https://files.pythonhosted.org/packages/c7/e4/0a94b09abe89e500dc748e7515f21a13e30c5c3fe3396e6d4ac108c25fca/numpy-2.3.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:f77e5b3d3da652b474cc80a14084927a5e86a5eccf54ca8ca5cbd697bf7f2667", size = 5115992, upload-time = "2025-10-15T16:15:50.144Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/db77c75b055c6157cbd4f9c92c4458daef0dd9cbe6d8d2fe7f803cb64c37/numpy-2.3.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:8ab1c5f5ee40d6e01cbe96de5863e39b215a4d24e7d007cad56c7184fdf4aeef", size = 6648672, upload-time = "2025-10-15T16:15:52.442Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e6/e31b0d713719610e406c0ea3ae0d90760465b086da8783e2fd835ad59027/numpy-2.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77b84453f3adcb994ddbd0d1c5d11db2d6bda1a2b7fd5ac5bd4649d6f5dc682e", size = 14284156, upload-time = "2025-10-15T16:15:54.351Z" }, + { url = "https://files.pythonhosted.org/packages/f9/58/30a85127bfee6f108282107caf8e06a1f0cc997cb6b52cdee699276fcce4/numpy-2.3.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4121c5beb58a7f9e6dfdee612cb24f4df5cd4db6e8261d7f4d7450a997a65d6a", size = 16641271, upload-time = "2025-10-15T16:15:56.67Z" }, + { url = "https://files.pythonhosted.org/packages/06/f2/2e06a0f2adf23e3ae29283ad96959267938d0efd20a2e25353b70065bfec/numpy-2.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:65611ecbb00ac9846efe04db15cbe6186f562f6bb7e5e05f077e53a599225d16", size = 16059531, upload-time = "2025-10-15T16:15:59.412Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e7/b106253c7c0d5dc352b9c8fab91afd76a93950998167fa3e5afe4ef3a18f/numpy-2.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dabc42f9c6577bcc13001b8810d300fe814b4cfbe8a92c873f269484594f9786", size = 18578983, upload-time = "2025-10-15T16:16:01.804Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/04ecc41e71462276ee867ccbef26a4448638eadecf1bc56772c9ed6d0255/numpy-2.3.4-cp312-cp312-win32.whl", hash = "sha256:a49d797192a8d950ca59ee2d0337a4d804f713bb5c3c50e8db26d49666e351dc", size = 6291380, upload-time = "2025-10-15T16:16:03.938Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a8/566578b10d8d0e9955b1b6cd5db4e9d4592dd0026a941ff7994cedda030a/numpy-2.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:985f1e46358f06c2a09921e8921e2c98168ed4ae12ccd6e5e87a4f1857923f32", size = 12787999, upload-time = "2025-10-15T16:16:05.801Z" }, + { url = "https://files.pythonhosted.org/packages/58/22/9c903a957d0a8071b607f5b1bff0761d6e608b9a965945411f867d515db1/numpy-2.3.4-cp312-cp312-win_arm64.whl", hash = "sha256:4635239814149e06e2cb9db3dd584b2fa64316c96f10656983b8026a82e6e4db", size = 10197412, upload-time = "2025-10-15T16:16:07.854Z" }, + { url = "https://files.pythonhosted.org/packages/57/7e/b72610cc91edf138bc588df5150957a4937221ca6058b825b4725c27be62/numpy-2.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c090d4860032b857d94144d1a9976b8e36709e40386db289aaf6672de2a81966", size = 20950335, upload-time = "2025-10-15T16:16:10.304Z" }, + { url = "https://files.pythonhosted.org/packages/3e/46/bdd3370dcea2f95ef14af79dbf81e6927102ddf1cc54adc0024d61252fd9/numpy-2.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a13fc473b6db0be619e45f11f9e81260f7302f8d180c49a22b6e6120022596b3", size = 14179878, upload-time = "2025-10-15T16:16:12.595Z" }, + { url = "https://files.pythonhosted.org/packages/ac/01/5a67cb785bda60f45415d09c2bc245433f1c68dd82eef9c9002c508b5a65/numpy-2.3.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:3634093d0b428e6c32c3a69b78e554f0cd20ee420dcad5a9f3b2a63762ce4197", size = 5108673, upload-time = "2025-10-15T16:16:14.877Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cd/8428e23a9fcebd33988f4cb61208fda832800ca03781f471f3727a820704/numpy-2.3.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:043885b4f7e6e232d7df4f51ffdef8c36320ee9d5f227b380ea636722c7ed12e", size = 6641438, upload-time = "2025-10-15T16:16:16.805Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d1/913fe563820f3c6b079f992458f7331278dcd7ba8427e8e745af37ddb44f/numpy-2.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ee6a571d1e4f0ea6d5f22d6e5fbd6ed1dc2b18542848e1e7301bd190500c9d7", size = 14281290, upload-time = "2025-10-15T16:16:18.764Z" }, + { url = "https://files.pythonhosted.org/packages/9e/7e/7d306ff7cb143e6d975cfa7eb98a93e73495c4deabb7d1b5ecf09ea0fd69/numpy-2.3.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc8a63918b04b8571789688b2780ab2b4a33ab44bfe8ccea36d3eba51228c953", size = 16636543, upload-time = "2025-10-15T16:16:21.072Z" }, + { url = "https://files.pythonhosted.org/packages/47/6a/8cfc486237e56ccfb0db234945552a557ca266f022d281a2f577b98e955c/numpy-2.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:40cc556d5abbc54aabe2b1ae287042d7bdb80c08edede19f0c0afb36ae586f37", size = 16056117, upload-time = "2025-10-15T16:16:23.369Z" }, + { url = "https://files.pythonhosted.org/packages/b1/0e/42cb5e69ea901e06ce24bfcc4b5664a56f950a70efdcf221f30d9615f3f3/numpy-2.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ecb63014bb7f4ce653f8be7f1df8cbc6093a5a2811211770f6606cc92b5a78fd", size = 18577788, upload-time = "2025-10-15T16:16:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/86/92/41c3d5157d3177559ef0a35da50f0cda7fa071f4ba2306dd36818591a5bc/numpy-2.3.4-cp313-cp313-win32.whl", hash = "sha256:e8370eb6925bb8c1c4264fec52b0384b44f675f191df91cbe0140ec9f0955646", size = 6282620, upload-time = "2025-10-15T16:16:29.811Z" }, + { url = "https://files.pythonhosted.org/packages/09/97/fd421e8bc50766665ad35536c2bb4ef916533ba1fdd053a62d96cc7c8b95/numpy-2.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:56209416e81a7893036eea03abcb91c130643eb14233b2515c90dcac963fe99d", size = 12784672, upload-time = "2025-10-15T16:16:31.589Z" }, + { url = "https://files.pythonhosted.org/packages/ad/df/5474fb2f74970ca8eb978093969b125a84cc3d30e47f82191f981f13a8a0/numpy-2.3.4-cp313-cp313-win_arm64.whl", hash = "sha256:a700a4031bc0fd6936e78a752eefb79092cecad2599ea9c8039c548bc097f9bc", size = 10196702, upload-time = "2025-10-15T16:16:33.902Z" }, + { url = "https://files.pythonhosted.org/packages/11/83/66ac031464ec1767ea3ed48ce40f615eb441072945e98693bec0bcd056cc/numpy-2.3.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:86966db35c4040fdca64f0816a1c1dd8dbd027d90fca5a57e00e1ca4cd41b879", size = 21049003, upload-time = "2025-10-15T16:16:36.101Z" }, + { url = "https://files.pythonhosted.org/packages/5f/99/5b14e0e686e61371659a1d5bebd04596b1d72227ce36eed121bb0aeab798/numpy-2.3.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:838f045478638b26c375ee96ea89464d38428c69170360b23a1a50fa4baa3562", size = 14302980, upload-time = "2025-10-15T16:16:39.124Z" }, + { url = "https://files.pythonhosted.org/packages/2c/44/e9486649cd087d9fc6920e3fc3ac2aba10838d10804b1e179fb7cbc4e634/numpy-2.3.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d7315ed1dab0286adca467377c8381cd748f3dc92235f22a7dfc42745644a96a", size = 5231472, upload-time = "2025-10-15T16:16:41.168Z" }, + { url = "https://files.pythonhosted.org/packages/3e/51/902b24fa8887e5fe2063fd61b1895a476d0bbf46811ab0c7fdf4bd127345/numpy-2.3.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:84f01a4d18b2cc4ade1814a08e5f3c907b079c847051d720fad15ce37aa930b6", size = 6739342, upload-time = "2025-10-15T16:16:43.777Z" }, + { url = "https://files.pythonhosted.org/packages/34/f1/4de9586d05b1962acdcdb1dc4af6646361a643f8c864cef7c852bf509740/numpy-2.3.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:817e719a868f0dacde4abdfc5c1910b301877970195db9ab6a5e2c4bd5b121f7", size = 14354338, upload-time = "2025-10-15T16:16:46.081Z" }, + { url = "https://files.pythonhosted.org/packages/1f/06/1c16103b425de7969d5a76bdf5ada0804b476fed05d5f9e17b777f1cbefd/numpy-2.3.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85e071da78d92a214212cacea81c6da557cab307f2c34b5f85b628e94803f9c0", size = 16702392, upload-time = "2025-10-15T16:16:48.455Z" }, + { url = "https://files.pythonhosted.org/packages/34/b2/65f4dc1b89b5322093572b6e55161bb42e3e0487067af73627f795cc9d47/numpy-2.3.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2ec646892819370cf3558f518797f16597b4e4669894a2ba712caccc9da53f1f", size = 16134998, upload-time = "2025-10-15T16:16:51.114Z" }, + { url = "https://files.pythonhosted.org/packages/d4/11/94ec578896cdb973aaf56425d6c7f2aff4186a5c00fac15ff2ec46998b46/numpy-2.3.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:035796aaaddfe2f9664b9a9372f089cfc88bd795a67bd1bfe15e6e770934cf64", size = 18651574, upload-time = "2025-10-15T16:16:53.429Z" }, + { url = "https://files.pythonhosted.org/packages/62/b7/7efa763ab33dbccf56dade36938a77345ce8e8192d6b39e470ca25ff3cd0/numpy-2.3.4-cp313-cp313t-win32.whl", hash = "sha256:fea80f4f4cf83b54c3a051f2f727870ee51e22f0248d3114b8e755d160b38cfb", size = 6413135, upload-time = "2025-10-15T16:16:55.992Z" }, + { url = "https://files.pythonhosted.org/packages/43/70/aba4c38e8400abcc2f345e13d972fb36c26409b3e644366db7649015f291/numpy-2.3.4-cp313-cp313t-win_amd64.whl", hash = "sha256:15eea9f306b98e0be91eb344a94c0e630689ef302e10c2ce5f7e11905c704f9c", size = 12928582, upload-time = "2025-10-15T16:16:57.943Z" }, + { url = "https://files.pythonhosted.org/packages/67/63/871fad5f0073fc00fbbdd7232962ea1ac40eeaae2bba66c76214f7954236/numpy-2.3.4-cp313-cp313t-win_arm64.whl", hash = "sha256:b6c231c9c2fadbae4011ca5e7e83e12dc4a5072f1a1d85a0a7b3ed754d145a40", size = 10266691, upload-time = "2025-10-15T16:17:00.048Z" }, + { url = "https://files.pythonhosted.org/packages/72/71/ae6170143c115732470ae3a2d01512870dd16e0953f8a6dc89525696069b/numpy-2.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:81c3e6d8c97295a7360d367f9f8553973651b76907988bb6066376bc2252f24e", size = 20955580, upload-time = "2025-10-15T16:17:02.509Z" }, + { url = "https://files.pythonhosted.org/packages/af/39/4be9222ffd6ca8a30eda033d5f753276a9c3426c397bb137d8e19dedd200/numpy-2.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7c26b0b2bf58009ed1f38a641f3db4be8d960a417ca96d14e5b06df1506d41ff", size = 14188056, upload-time = "2025-10-15T16:17:04.873Z" }, + { url = "https://files.pythonhosted.org/packages/6c/3d/d85f6700d0a4aa4f9491030e1021c2b2b7421b2b38d01acd16734a2bfdc7/numpy-2.3.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:62b2198c438058a20b6704351b35a1d7db881812d8512d67a69c9de1f18ca05f", size = 5116555, upload-time = "2025-10-15T16:17:07.499Z" }, + { url = "https://files.pythonhosted.org/packages/bf/04/82c1467d86f47eee8a19a464c92f90a9bb68ccf14a54c5224d7031241ffb/numpy-2.3.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:9d729d60f8d53a7361707f4b68a9663c968882dd4f09e0d58c044c8bf5faee7b", size = 6643581, upload-time = "2025-10-15T16:17:09.774Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d3/c79841741b837e293f48bd7db89d0ac7a4f2503b382b78a790ef1dc778a5/numpy-2.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd0c630cf256b0a7fd9d0a11c9413b42fef5101219ce6ed5a09624f5a65392c7", size = 14299186, upload-time = "2025-10-15T16:17:11.937Z" }, + { url = "https://files.pythonhosted.org/packages/e8/7e/4a14a769741fbf237eec5a12a2cbc7a4c4e061852b6533bcb9e9a796c908/numpy-2.3.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5e081bc082825f8b139f9e9fe42942cb4054524598aaeb177ff476cc76d09d2", size = 16638601, upload-time = "2025-10-15T16:17:14.391Z" }, + { url = "https://files.pythonhosted.org/packages/93/87/1c1de269f002ff0a41173fe01dcc925f4ecff59264cd8f96cf3b60d12c9b/numpy-2.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15fb27364ed84114438fff8aaf998c9e19adbeba08c0b75409f8c452a8692c52", size = 16074219, upload-time = "2025-10-15T16:17:17.058Z" }, + { url = "https://files.pythonhosted.org/packages/cd/28/18f72ee77408e40a76d691001ae599e712ca2a47ddd2c4f695b16c65f077/numpy-2.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:85d9fb2d8cd998c84d13a79a09cc0c1091648e848e4e6249b0ccd7f6b487fa26", size = 18576702, upload-time = "2025-10-15T16:17:19.379Z" }, + { url = "https://files.pythonhosted.org/packages/c3/76/95650169b465ececa8cf4b2e8f6df255d4bf662775e797ade2025cc51ae6/numpy-2.3.4-cp314-cp314-win32.whl", hash = "sha256:e73d63fd04e3a9d6bc187f5455d81abfad05660b212c8804bf3b407e984cd2bc", size = 6337136, upload-time = "2025-10-15T16:17:22.886Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/a231a5c43ede5d6f77ba4a91e915a87dea4aeea76560ba4d2bf185c683f0/numpy-2.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:3da3491cee49cf16157e70f607c03a217ea6647b1cea4819c4f48e53d49139b9", size = 12920542, upload-time = "2025-10-15T16:17:24.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/0c/ae9434a888f717c5ed2ff2393b3f344f0ff6f1c793519fa0c540461dc530/numpy-2.3.4-cp314-cp314-win_arm64.whl", hash = "sha256:6d9cd732068e8288dbe2717177320723ccec4fb064123f0caf9bbd90ab5be868", size = 10480213, upload-time = "2025-10-15T16:17:26.935Z" }, + { url = "https://files.pythonhosted.org/packages/83/4b/c4a5f0841f92536f6b9592694a5b5f68c9ab37b775ff342649eadf9055d3/numpy-2.3.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:22758999b256b595cf0b1d102b133bb61866ba5ceecf15f759623b64c020c9ec", size = 21052280, upload-time = "2025-10-15T16:17:29.638Z" }, + { url = "https://files.pythonhosted.org/packages/3e/80/90308845fc93b984d2cc96d83e2324ce8ad1fd6efea81b324cba4b673854/numpy-2.3.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9cb177bc55b010b19798dc5497d540dea67fd13a8d9e882b2dae71de0cf09eb3", size = 14302930, upload-time = "2025-10-15T16:17:32.384Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4e/07439f22f2a3b247cec4d63a713faae55e1141a36e77fb212881f7cda3fb/numpy-2.3.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0f2bcc76f1e05e5ab58893407c63d90b2029908fa41f9f1cc51eecce936c3365", size = 5231504, upload-time = "2025-10-15T16:17:34.515Z" }, + { url = "https://files.pythonhosted.org/packages/ab/de/1e11f2547e2fe3d00482b19721855348b94ada8359aef5d40dd57bfae9df/numpy-2.3.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8dc20bde86802df2ed8397a08d793da0ad7a5fd4ea3ac85d757bf5dd4ad7c252", size = 6739405, upload-time = "2025-10-15T16:17:36.128Z" }, + { url = "https://files.pythonhosted.org/packages/3b/40/8cd57393a26cebe2e923005db5134a946c62fa56a1087dc7c478f3e30837/numpy-2.3.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e199c087e2aa71c8f9ce1cb7a8e10677dc12457e7cc1be4798632da37c3e86e", size = 14354866, upload-time = "2025-10-15T16:17:38.884Z" }, + { url = "https://files.pythonhosted.org/packages/93/39/5b3510f023f96874ee6fea2e40dfa99313a00bf3ab779f3c92978f34aace/numpy-2.3.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85597b2d25ddf655495e2363fe044b0ae999b75bc4d630dc0d886484b03a5eb0", size = 16703296, upload-time = "2025-10-15T16:17:41.564Z" }, + { url = "https://files.pythonhosted.org/packages/41/0d/19bb163617c8045209c1996c4e427bccbc4bbff1e2c711f39203c8ddbb4a/numpy-2.3.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:04a69abe45b49c5955923cf2c407843d1c85013b424ae8a560bba16c92fe44a0", size = 16136046, upload-time = "2025-10-15T16:17:43.901Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c1/6dba12fdf68b02a21ac411c9df19afa66bed2540f467150ca64d246b463d/numpy-2.3.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e1708fac43ef8b419c975926ce1eaf793b0c13b7356cfab6ab0dc34c0a02ac0f", size = 18652691, upload-time = "2025-10-15T16:17:46.247Z" }, + { url = "https://files.pythonhosted.org/packages/f8/73/f85056701dbbbb910c51d846c58d29fd46b30eecd2b6ba760fc8b8a1641b/numpy-2.3.4-cp314-cp314t-win32.whl", hash = "sha256:863e3b5f4d9915aaf1b8ec79ae560ad21f0b8d5e3adc31e73126491bb86dee1d", size = 6485782, upload-time = "2025-10-15T16:17:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/17/90/28fa6f9865181cb817c2471ee65678afa8a7e2a1fb16141473d5fa6bacc3/numpy-2.3.4-cp314-cp314t-win_amd64.whl", hash = "sha256:962064de37b9aef801d33bc579690f8bfe6c5e70e29b61783f60bcba838a14d6", size = 13113301, upload-time = "2025-10-15T16:17:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/54/23/08c002201a8e7e1f9afba93b97deceb813252d9cfd0d3351caed123dcf97/numpy-2.3.4-cp314-cp314t-win_arm64.whl", hash = "sha256:8b5a9a39c45d852b62693d9b3f3e0fe052541f804296ff401a72a1b60edafb29", size = 10547532, upload-time = "2025-10-15T16:17:53.48Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b6/64898f51a86ec88ca1257a59c1d7fd077b60082a119affefcdf1dd0df8ca/numpy-2.3.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6e274603039f924c0fe5cb73438fa9246699c78a6df1bd3decef9ae592ae1c05", size = 21131552, upload-time = "2025-10-15T16:17:55.845Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4c/f135dc6ebe2b6a3c77f4e4838fa63d350f85c99462012306ada1bd4bc460/numpy-2.3.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d149aee5c72176d9ddbc6803aef9c0f6d2ceeea7626574fc68518da5476fa346", size = 14377796, upload-time = "2025-10-15T16:17:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a4/f33f9c23fcc13dd8412fc8614559b5b797e0aba9d8e01dfa8bae10c84004/numpy-2.3.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:6d34ed9db9e6395bb6cd33286035f73a59b058169733a9db9f85e650b88df37e", size = 5306904, upload-time = "2025-10-15T16:18:00.596Z" }, + { url = "https://files.pythonhosted.org/packages/28/af/c44097f25f834360f9fb960fa082863e0bad14a42f36527b2a121abdec56/numpy-2.3.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:fdebe771ca06bb8d6abce84e51dca9f7921fe6ad34a0c914541b063e9a68928b", size = 6819682, upload-time = "2025-10-15T16:18:02.32Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8c/cd283b54c3c2b77e188f63e23039844f56b23bba1712318288c13fe86baf/numpy-2.3.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e92defe6c08211eb77902253b14fe5b480ebc5112bc741fd5e9cd0608f847", size = 14422300, upload-time = "2025-10-15T16:18:04.271Z" }, + { url = "https://files.pythonhosted.org/packages/b0/f0/8404db5098d92446b3e3695cf41c6f0ecb703d701cb0b7566ee2177f2eee/numpy-2.3.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13b9062e4f5c7ee5c7e5be96f29ba71bc5a37fed3d1d77c37390ae00724d296d", size = 16760806, upload-time = "2025-10-15T16:18:06.668Z" }, + { url = "https://files.pythonhosted.org/packages/95/8e/2844c3959ce9a63acc7c8e50881133d86666f0420bcde695e115ced0920f/numpy-2.3.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:81b3a59793523e552c4a96109dde028aa4448ae06ccac5a76ff6532a85558a7f", size = 12973130, upload-time = "2025-10-15T16:18:09.397Z" }, ] [[package]] name = "packaging" version = "25.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 } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 }, + { 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" }, ] [[package]] name = "paginate" version = "0.5.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252 } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746 }, + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, ] [[package]] @@ -616,147 +839,191 @@ name = "pandas" version = "2.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.3.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "python-dateutil" }, { name = "pytz" }, { name = "tzdata" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846 }, - { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618 }, - { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212 }, - { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693 }, - { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002 }, - { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971 }, - { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722 }, - { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671 }, - { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807 }, - { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872 }, - { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371 }, - { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333 }, - { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120 }, - { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991 }, - { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227 }, - { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056 }, - { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189 }, - { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912 }, - { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160 }, - { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233 }, - { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635 }, - { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079 }, - { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049 }, - { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638 }, - { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834 }, - { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925 }, - { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071 }, - { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504 }, - { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702 }, - { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535 }, - { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582 }, - { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963 }, - { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175 }, +sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/f7/f425a00df4fcc22b292c6895c6831c0c8ae1d9fac1e024d16f98a9ce8749/pandas-2.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c", size = 11555763, upload-time = "2025-09-29T23:16:53.287Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/66d99628ff8ce7857aca52fed8f0066ce209f96be2fede6cef9f84e8d04f/pandas-2.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a", size = 10801217, upload-time = "2025-09-29T23:17:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/1d/03/3fc4a529a7710f890a239cc496fc6d50ad4a0995657dccc1d64695adb9f4/pandas-2.3.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf26f64126b6c7aec964f74266f435afef1c1b13da3b0636c7518a1fa3e2b1", size = 12148791, upload-time = "2025-09-29T23:17:18.444Z" }, + { url = "https://files.pythonhosted.org/packages/40/a8/4dac1f8f8235e5d25b9955d02ff6f29396191d4e665d71122c3722ca83c5/pandas-2.3.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd7478f1463441ae4ca7308a70e90b33470fa593429f9d4c578dd00d1fa78838", size = 12769373, upload-time = "2025-09-29T23:17:35.846Z" }, + { url = "https://files.pythonhosted.org/packages/df/91/82cc5169b6b25440a7fc0ef3a694582418d875c8e3ebf796a6d6470aa578/pandas-2.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4793891684806ae50d1288c9bae9330293ab4e083ccd1c5e383c34549c6e4250", size = 13200444, upload-time = "2025-09-29T23:17:49.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/ae/89b3283800ab58f7af2952704078555fa60c807fff764395bb57ea0b0dbd/pandas-2.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28083c648d9a99a5dd035ec125d42439c6c1c525098c58af0fc38dd1a7a1b3d4", size = 13858459, upload-time = "2025-09-29T23:18:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/85/72/530900610650f54a35a19476eca5104f38555afccda1aa11a92ee14cb21d/pandas-2.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:503cf027cf9940d2ceaa1a93cfb5f8c8c7e6e90720a2850378f0b3f3b1e06826", size = 11346086, upload-time = "2025-09-29T23:18:18.505Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fa/7ac648108144a095b4fb6aa3de1954689f7af60a14cf25583f4960ecb878/pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523", size = 11578790, upload-time = "2025-09-29T23:18:30.065Z" }, + { url = "https://files.pythonhosted.org/packages/9b/35/74442388c6cf008882d4d4bdfc4109be87e9b8b7ccd097ad1e7f006e2e95/pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45", size = 10833831, upload-time = "2025-09-29T23:38:56.071Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e4/de154cbfeee13383ad58d23017da99390b91d73f8c11856f2095e813201b/pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66", size = 12199267, upload-time = "2025-09-29T23:18:41.627Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b", size = 12789281, upload-time = "2025-09-29T23:18:56.834Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/a5ac8c7a0e67fd1a6059e40aa08fa1c52cc00709077d2300e210c3ce0322/pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791", size = 13240453, upload-time = "2025-09-29T23:19:09.247Z" }, + { url = "https://files.pythonhosted.org/packages/27/4d/5c23a5bc7bd209231618dd9e606ce076272c9bc4f12023a70e03a86b4067/pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151", size = 13890361, upload-time = "2025-09-29T23:19:25.342Z" }, + { url = "https://files.pythonhosted.org/packages/8e/59/712db1d7040520de7a4965df15b774348980e6df45c129b8c64d0dbe74ef/pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c", size = 11348702, upload-time = "2025-09-29T23:19:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, + { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, + { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, + { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, + { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" }, + { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" }, + { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, + { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" }, + { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, + { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" }, + { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635, upload-time = "2025-09-29T23:25:52.486Z" }, + { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079, upload-time = "2025-09-29T23:26:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049, upload-time = "2025-09-29T23:27:15.384Z" }, + { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638, upload-time = "2025-09-29T23:27:51.625Z" }, + { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834, upload-time = "2025-09-29T23:28:21.289Z" }, + { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925, upload-time = "2025-09-29T23:28:58.261Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071, upload-time = "2025-09-29T23:32:27.484Z" }, + { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504, upload-time = "2025-09-29T23:29:31.47Z" }, + { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702, upload-time = "2025-09-29T23:29:54.591Z" }, + { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535, upload-time = "2025-09-29T23:30:21.003Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582, upload-time = "2025-09-29T23:30:43.391Z" }, + { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963, upload-time = "2025-09-29T23:31:10.009Z" }, + { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, ] [[package]] name = "pathspec" version = "0.12.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043 } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 }, + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, ] [[package]] name = "pillow" version = "12.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/90/4fcce2c22caf044e660a198d740e7fbc14395619e3cb1abad12192c0826c/pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", size = 5249377 }, - { url = "https://files.pythonhosted.org/packages/fd/e0/ed960067543d080691d47d6938ebccbf3976a931c9567ab2fbfab983a5dd/pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", size = 4650343 }, - { url = "https://files.pythonhosted.org/packages/e7/a1/f81fdeddcb99c044bf7d6faa47e12850f13cee0849537a7d27eeab5534d4/pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", size = 6232981 }, - { url = "https://files.pythonhosted.org/packages/88/e1/9098d3ce341a8750b55b0e00c03f1630d6178f38ac191c81c97a3b047b44/pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d", size = 8041399 }, - { url = "https://files.pythonhosted.org/packages/a7/62/a22e8d3b602ae8cc01446d0c57a54e982737f44b6f2e1e019a925143771d/pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953", size = 6347740 }, - { url = "https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8", size = 7040201 }, - { url = "https://files.pythonhosted.org/packages/dc/4d/435c8ac688c54d11755aedfdd9f29c9eeddf68d150fe42d1d3dbd2365149/pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79", size = 6462334 }, - { url = "https://files.pythonhosted.org/packages/2b/f2/ad34167a8059a59b8ad10bc5c72d4d9b35acc6b7c0877af8ac885b5f2044/pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba", size = 7134162 }, - { url = "https://files.pythonhosted.org/packages/0c/b1/a7391df6adacf0a5c2cf6ac1cf1fcc1369e7d439d28f637a847f8803beb3/pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", size = 6298769 }, - { url = "https://files.pythonhosted.org/packages/a2/0b/d87733741526541c909bbf159e338dcace4f982daac6e5a8d6be225ca32d/pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", size = 7001107 }, - { url = "https://files.pythonhosted.org/packages/bc/96/aaa61ce33cc98421fb6088af2a03be4157b1e7e0e87087c888e2370a7f45/pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", size = 2436012 }, - { url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493 }, - { url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461 }, - { url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912 }, - { url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132 }, - { url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099 }, - { url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808 }, - { url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804 }, - { url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553 }, - { url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729 }, - { url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789 }, - { url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917 }, - { url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391 }, - { url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477 }, - { url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918 }, - { url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406 }, - { url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218 }, - { url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564 }, - { url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260 }, - { url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248 }, - { url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043 }, - { url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915 }, - { url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998 }, - { url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201 }, - { url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165 }, - { url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834 }, - { url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531 }, - { url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554 }, - { url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812 }, - { url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689 }, - { url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186 }, - { url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308 }, - { url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222 }, - { url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657 }, - { url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482 }, - { url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416 }, - { url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584 }, - { url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621 }, - { url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916 }, - { url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836 }, - { url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092 }, - { url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158 }, - { url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882 }, - { url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001 }, - { url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146 }, - { url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344 }, - { url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864 }, - { url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911 }, - { url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045 }, - { url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282 }, - { url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630 }, +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/08/26e68b6b5da219c2a2cb7b563af008b53bb8e6b6fcb3fa40715fcdb2523a/pillow-12.0.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:3adfb466bbc544b926d50fe8f4a4e6abd8c6bffd28a26177594e6e9b2b76572b", size = 5289809, upload-time = "2025-10-15T18:21:27.791Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/4e58fb097fb74c7b4758a680aacd558810a417d1edaa7000142976ef9d2f/pillow-12.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1ac11e8ea4f611c3c0147424eae514028b5e9077dd99ab91e1bd7bc33ff145e1", size = 4650606, upload-time = "2025-10-15T18:21:29.823Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e0/1fa492aa9f77b3bc6d471c468e62bfea1823056bf7e5e4f1914d7ab2565e/pillow-12.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d49e2314c373f4c2b39446fb1a45ed333c850e09d0c59ac79b72eb3b95397363", size = 6221023, upload-time = "2025-10-15T18:21:31.415Z" }, + { url = "https://files.pythonhosted.org/packages/c1/09/4de7cd03e33734ccd0c876f0251401f1314e819cbfd89a0fcb6e77927cc6/pillow-12.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c7b2a63fd6d5246349f3d3f37b14430d73ee7e8173154461785e43036ffa96ca", size = 8024937, upload-time = "2025-10-15T18:21:33.453Z" }, + { url = "https://files.pythonhosted.org/packages/2e/69/0688e7c1390666592876d9d474f5e135abb4acb39dcb583c4dc5490f1aff/pillow-12.0.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d64317d2587c70324b79861babb9c09f71fbb780bad212018874b2c013d8600e", size = 6334139, upload-time = "2025-10-15T18:21:35.395Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1c/880921e98f525b9b44ce747ad1ea8f73fd7e992bafe3ca5e5644bf433dea/pillow-12.0.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d77153e14b709fd8b8af6f66a3afbb9ed6e9fc5ccf0b6b7e1ced7b036a228782", size = 7026074, upload-time = "2025-10-15T18:21:37.219Z" }, + { url = "https://files.pythonhosted.org/packages/28/03/96f718331b19b355610ef4ebdbbde3557c726513030665071fd025745671/pillow-12.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:32ed80ea8a90ee3e6fa08c21e2e091bba6eda8eccc83dbc34c95169507a91f10", size = 6448852, upload-time = "2025-10-15T18:21:39.168Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a0/6a193b3f0cc9437b122978d2c5cbce59510ccf9a5b48825096ed7472da2f/pillow-12.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c828a1ae702fc712978bda0320ba1b9893d99be0badf2647f693cc01cf0f04fa", size = 7117058, upload-time = "2025-10-15T18:21:40.997Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c4/043192375eaa4463254e8e61f0e2ec9a846b983929a8d0a7122e0a6d6fff/pillow-12.0.0-cp310-cp310-win32.whl", hash = "sha256:bd87e140e45399c818fac4247880b9ce719e4783d767e030a883a970be632275", size = 6295431, upload-time = "2025-10-15T18:21:42.518Z" }, + { url = "https://files.pythonhosted.org/packages/92/c6/c2f2fc7e56301c21827e689bb8b0b465f1b52878b57471a070678c0c33cd/pillow-12.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:455247ac8a4cfb7b9bc45b7e432d10421aea9fc2e74d285ba4072688a74c2e9d", size = 7000412, upload-time = "2025-10-15T18:21:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d2/5f675067ba82da7a1c238a73b32e3fd78d67f9d9f80fbadd33a40b9c0481/pillow-12.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:6ace95230bfb7cd79ef66caa064bbe2f2a1e63d93471c3a2e1f1348d9f22d6b7", size = 2435903, upload-time = "2025-10-15T18:21:46.29Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5a/a2f6773b64edb921a756eb0729068acad9fc5208a53f4a349396e9436721/pillow-12.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc", size = 5289798, upload-time = "2025-10-15T18:21:47.763Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/069b1f8a2e4b5a37493da6c5868531c3f77b85e716ad7a590ef87d58730d/pillow-12.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257", size = 4650589, upload-time = "2025-10-15T18:21:49.515Z" }, + { url = "https://files.pythonhosted.org/packages/61/e3/2c820d6e9a36432503ead175ae294f96861b07600a7156154a086ba7111a/pillow-12.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642", size = 6230472, upload-time = "2025-10-15T18:21:51.052Z" }, + { url = "https://files.pythonhosted.org/packages/4f/89/63427f51c64209c5e23d4d52071c8d0f21024d3a8a487737caaf614a5795/pillow-12.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3", size = 8033887, upload-time = "2025-10-15T18:21:52.604Z" }, + { url = "https://files.pythonhosted.org/packages/f6/1b/c9711318d4901093c15840f268ad649459cd81984c9ec9887756cca049a5/pillow-12.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c", size = 6343964, upload-time = "2025-10-15T18:21:54.619Z" }, + { url = "https://files.pythonhosted.org/packages/41/1e/db9470f2d030b4995083044cd8738cdd1bf773106819f6d8ba12597d5352/pillow-12.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227", size = 7034756, upload-time = "2025-10-15T18:21:56.151Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b0/6177a8bdd5ee4ed87cba2de5a3cc1db55ffbbec6176784ce5bb75aa96798/pillow-12.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b", size = 6458075, upload-time = "2025-10-15T18:21:57.759Z" }, + { url = "https://files.pythonhosted.org/packages/bc/5e/61537aa6fa977922c6a03253a0e727e6e4a72381a80d63ad8eec350684f2/pillow-12.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e", size = 7125955, upload-time = "2025-10-15T18:21:59.372Z" }, + { url = "https://files.pythonhosted.org/packages/1f/3d/d5033539344ee3cbd9a4d69e12e63ca3a44a739eb2d4c8da350a3d38edd7/pillow-12.0.0-cp311-cp311-win32.whl", hash = "sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739", size = 6298440, upload-time = "2025-10-15T18:22:00.982Z" }, + { url = "https://files.pythonhosted.org/packages/4d/42/aaca386de5cc8bd8a0254516957c1f265e3521c91515b16e286c662854c4/pillow-12.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e", size = 6999256, upload-time = "2025-10-15T18:22:02.617Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f1/9197c9c2d5708b785f631a6dfbfa8eb3fb9672837cb92ae9af812c13b4ed/pillow-12.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d", size = 2436025, upload-time = "2025-10-15T18:22:04.598Z" }, + { url = "https://files.pythonhosted.org/packages/2c/90/4fcce2c22caf044e660a198d740e7fbc14395619e3cb1abad12192c0826c/pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", size = 5249377, upload-time = "2025-10-15T18:22:05.993Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/ed960067543d080691d47d6938ebccbf3976a931c9567ab2fbfab983a5dd/pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", size = 4650343, upload-time = "2025-10-15T18:22:07.718Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a1/f81fdeddcb99c044bf7d6faa47e12850f13cee0849537a7d27eeab5534d4/pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", size = 6232981, upload-time = "2025-10-15T18:22:09.287Z" }, + { url = "https://files.pythonhosted.org/packages/88/e1/9098d3ce341a8750b55b0e00c03f1630d6178f38ac191c81c97a3b047b44/pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d", size = 8041399, upload-time = "2025-10-15T18:22:10.872Z" }, + { url = "https://files.pythonhosted.org/packages/a7/62/a22e8d3b602ae8cc01446d0c57a54e982737f44b6f2e1e019a925143771d/pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953", size = 6347740, upload-time = "2025-10-15T18:22:12.769Z" }, + { url = "https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8", size = 7040201, upload-time = "2025-10-15T18:22:14.813Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4d/435c8ac688c54d11755aedfdd9f29c9eeddf68d150fe42d1d3dbd2365149/pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79", size = 6462334, upload-time = "2025-10-15T18:22:16.375Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f2/ad34167a8059a59b8ad10bc5c72d4d9b35acc6b7c0877af8ac885b5f2044/pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba", size = 7134162, upload-time = "2025-10-15T18:22:17.996Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/a7391df6adacf0a5c2cf6ac1cf1fcc1369e7d439d28f637a847f8803beb3/pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", size = 6298769, upload-time = "2025-10-15T18:22:19.923Z" }, + { url = "https://files.pythonhosted.org/packages/a2/0b/d87733741526541c909bbf159e338dcace4f982daac6e5a8d6be225ca32d/pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", size = 7001107, upload-time = "2025-10-15T18:22:21.644Z" }, + { url = "https://files.pythonhosted.org/packages/bc/96/aaa61ce33cc98421fb6088af2a03be4157b1e7e0e87087c888e2370a7f45/pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", size = 2436012, upload-time = "2025-10-15T18:22:23.621Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493, upload-time = "2025-10-15T18:22:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461, upload-time = "2025-10-15T18:22:27.286Z" }, + { url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912, upload-time = "2025-10-15T18:22:28.751Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132, upload-time = "2025-10-15T18:22:30.641Z" }, + { url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099, upload-time = "2025-10-15T18:22:32.73Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808, upload-time = "2025-10-15T18:22:34.337Z" }, + { url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804, upload-time = "2025-10-15T18:22:36.402Z" }, + { url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553, upload-time = "2025-10-15T18:22:38.066Z" }, + { url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729, upload-time = "2025-10-15T18:22:39.769Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789, upload-time = "2025-10-15T18:22:41.437Z" }, + { url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917, upload-time = "2025-10-15T18:22:43.152Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391, upload-time = "2025-10-15T18:22:44.753Z" }, + { url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477, upload-time = "2025-10-15T18:22:46.838Z" }, + { url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918, upload-time = "2025-10-15T18:22:48.399Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406, upload-time = "2025-10-15T18:22:49.905Z" }, + { url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218, upload-time = "2025-10-15T18:22:51.587Z" }, + { url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564, upload-time = "2025-10-15T18:22:53.215Z" }, + { url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260, upload-time = "2025-10-15T18:22:54.933Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248, upload-time = "2025-10-15T18:22:56.605Z" }, + { url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043, upload-time = "2025-10-15T18:22:58.53Z" }, + { url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915, upload-time = "2025-10-15T18:23:00.582Z" }, + { url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998, upload-time = "2025-10-15T18:23:02.627Z" }, + { url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201, upload-time = "2025-10-15T18:23:04.709Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165, upload-time = "2025-10-15T18:23:06.46Z" }, + { url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834, upload-time = "2025-10-15T18:23:08.194Z" }, + { url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" }, + { url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" }, + { url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" }, + { url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" }, + { url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" }, + { url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" }, + { url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" }, + { url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" }, + { url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" }, + { url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" }, + { url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" }, + { url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" }, + { url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" }, + { url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" }, + { url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" }, + { url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" }, + { url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b3/582327e6c9f86d037b63beebe981425d6811104cb443e8193824ef1a2f27/pillow-12.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8", size = 5215068, upload-time = "2025-10-15T18:23:59.594Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d6/67748211d119f3b6540baf90f92fae73ae51d5217b171b0e8b5f7e5d558f/pillow-12.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a", size = 4614994, upload-time = "2025-10-15T18:24:01.669Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e1/f8281e5d844c41872b273b9f2c34a4bf64ca08905668c8ae730eedc7c9fa/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197", size = 5246639, upload-time = "2025-10-15T18:24:03.403Z" }, + { url = "https://files.pythonhosted.org/packages/94/5a/0d8ab8ffe8a102ff5df60d0de5af309015163bf710c7bb3e8311dd3b3ad0/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c", size = 6986839, upload-time = "2025-10-15T18:24:05.344Z" }, + { url = "https://files.pythonhosted.org/packages/20/2e/3434380e8110b76cd9eb00a363c484b050f949b4bbe84ba770bb8508a02c/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e", size = 5313505, upload-time = "2025-10-15T18:24:07.137Z" }, + { url = "https://files.pythonhosted.org/packages/57/ca/5a9d38900d9d74785141d6580950fe705de68af735ff6e727cb911b64740/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76", size = 5963654, upload-time = "2025-10-15T18:24:09.579Z" }, + { url = "https://files.pythonhosted.org/packages/95/7e/f896623c3c635a90537ac093c6a618ebe1a90d87206e42309cb5d98a1b9e/pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5", size = 6997850, upload-time = "2025-10-15T18:24:11.495Z" }, ] [[package]] name = "platformdirs" version = "4.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632 } +sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651 }, + { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, ] [[package]] name = "pluggy" version = "1.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, + { 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 = "pydantic" -version = "2.12.3" +version = "2.12.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -764,85 +1031,136 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f3/1e/4f0a3233767010308f2fd6bd0814597e3f63f1dc98304a9112b8759df4ff/pydantic-2.12.3.tar.gz", hash = "sha256:1da1c82b0fc140bb0103bc1441ffe062154c8d38491189751ee00fd8ca65ce74", size = 819383 } +sdist = { url = "https://files.pythonhosted.org/packages/96/ad/a17bc283d7d81837c061c49e3eaa27a45991759a1b7eae1031921c6bd924/pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac", size = 821038, upload-time = "2025-11-05T10:50:08.59Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/6b/83661fa77dcefa195ad5f8cd9af3d1a7450fd57cc883ad04d65446ac2029/pydantic-2.12.3-py3-none-any.whl", hash = "sha256:6986454a854bc3bc6e5443e1369e06a3a456af9d339eda45510f517d9ea5c6bf", size = 462431 }, + { url = "https://files.pythonhosted.org/packages/82/2f/e68750da9b04856e2a7ec56fc6f034a5a79775e9b9a81882252789873798/pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e", size = 463400, upload-time = "2025-11-05T10:50:06.732Z" }, ] [[package]] name = "pydantic-core" -version = "2.41.4" +version = "2.41.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/18/d0944e8eaaa3efd0a91b0f1fc537d3be55ad35091b6a87638211ba691964/pydantic_core-2.41.4.tar.gz", hash = "sha256:70e47929a9d4a1905a67e4b687d5946026390568a8e952b92824118063cee4d5", size = 457557 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/81/d3b3e95929c4369d30b2a66a91db63c8ed0a98381ae55a45da2cd1cc1288/pydantic_core-2.41.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ab06d77e053d660a6faaf04894446df7b0a7e7aba70c2797465a0a1af00fc887", size = 2099043 }, - { url = "https://files.pythonhosted.org/packages/58/da/46fdac49e6717e3a94fc9201403e08d9d61aa7a770fab6190b8740749047/pydantic_core-2.41.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c53ff33e603a9c1179a9364b0a24694f183717b2e0da2b5ad43c316c956901b2", size = 1910699 }, - { url = "https://files.pythonhosted.org/packages/1e/63/4d948f1b9dd8e991a5a98b77dd66c74641f5f2e5225fee37994b2e07d391/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:304c54176af2c143bd181d82e77c15c41cbacea8872a2225dd37e6544dce9999", size = 1952121 }, - { url = "https://files.pythonhosted.org/packages/b2/a7/e5fc60a6f781fc634ecaa9ecc3c20171d238794cef69ae0af79ac11b89d7/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:025ba34a4cf4fb32f917d5d188ab5e702223d3ba603be4d8aca2f82bede432a4", size = 2041590 }, - { url = "https://files.pythonhosted.org/packages/70/69/dce747b1d21d59e85af433428978a1893c6f8a7068fa2bb4a927fba7a5ff/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9f5f30c402ed58f90c70e12eff65547d3ab74685ffe8283c719e6bead8ef53f", size = 2219869 }, - { url = "https://files.pythonhosted.org/packages/83/6a/c070e30e295403bf29c4df1cb781317b6a9bac7cd07b8d3acc94d501a63c/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd96e5d15385d301733113bcaa324c8bcf111275b7675a9c6e88bfb19fc05e3b", size = 2345169 }, - { url = "https://files.pythonhosted.org/packages/f0/83/06d001f8043c336baea7fd202a9ac7ad71f87e1c55d8112c50b745c40324/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98f348cbb44fae6e9653c1055db7e29de67ea6a9ca03a5fa2c2e11a47cff0e47", size = 2070165 }, - { url = "https://files.pythonhosted.org/packages/14/0a/e567c2883588dd12bcbc110232d892cf385356f7c8a9910311ac997ab715/pydantic_core-2.41.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec22626a2d14620a83ca583c6f5a4080fa3155282718b6055c2ea48d3ef35970", size = 2189067 }, - { url = "https://files.pythonhosted.org/packages/f4/1d/3d9fca34273ba03c9b1c5289f7618bc4bd09c3ad2289b5420481aa051a99/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3a95d4590b1f1a43bf33ca6d647b990a88f4a3824a8c4572c708f0b45a5290ed", size = 2132997 }, - { url = "https://files.pythonhosted.org/packages/52/70/d702ef7a6cd41a8afc61f3554922b3ed8d19dd54c3bd4bdbfe332e610827/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:f9672ab4d398e1b602feadcffcdd3af44d5f5e6ddc15bc7d15d376d47e8e19f8", size = 2307187 }, - { url = "https://files.pythonhosted.org/packages/68/4c/c06be6e27545d08b802127914156f38d10ca287a9e8489342793de8aae3c/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:84d8854db5f55fead3b579f04bda9a36461dab0730c5d570e1526483e7bb8431", size = 2305204 }, - { url = "https://files.pythonhosted.org/packages/b0/e5/35ae4919bcd9f18603419e23c5eaf32750224a89d41a8df1a3704b69f77e/pydantic_core-2.41.4-cp312-cp312-win32.whl", hash = "sha256:9be1c01adb2ecc4e464392c36d17f97e9110fbbc906bcbe1c943b5b87a74aabd", size = 1972536 }, - { url = "https://files.pythonhosted.org/packages/1e/c2/49c5bb6d2a49eb2ee3647a93e3dae7080c6409a8a7558b075027644e879c/pydantic_core-2.41.4-cp312-cp312-win_amd64.whl", hash = "sha256:d682cf1d22bab22a5be08539dca3d1593488a99998f9f412137bc323179067ff", size = 2031132 }, - { url = "https://files.pythonhosted.org/packages/06/23/936343dbcba6eec93f73e95eb346810fc732f71ba27967b287b66f7b7097/pydantic_core-2.41.4-cp312-cp312-win_arm64.whl", hash = "sha256:833eebfd75a26d17470b58768c1834dfc90141b7afc6eb0429c21fc5a21dcfb8", size = 1969483 }, - { url = "https://files.pythonhosted.org/packages/13/d0/c20adabd181a029a970738dfe23710b52a31f1258f591874fcdec7359845/pydantic_core-2.41.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:85e050ad9e5f6fe1004eec65c914332e52f429bc0ae12d6fa2092407a462c746", size = 2105688 }, - { url = "https://files.pythonhosted.org/packages/00/b6/0ce5c03cec5ae94cca220dfecddc453c077d71363b98a4bbdb3c0b22c783/pydantic_core-2.41.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7393f1d64792763a48924ba31d1e44c2cfbc05e3b1c2c9abb4ceeadd912cced", size = 1910807 }, - { url = "https://files.pythonhosted.org/packages/68/3e/800d3d02c8beb0b5c069c870cbb83799d085debf43499c897bb4b4aaff0d/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94dab0940b0d1fb28bcab847adf887c66a27a40291eedf0b473be58761c9799a", size = 1956669 }, - { url = "https://files.pythonhosted.org/packages/60/a4/24271cc71a17f64589be49ab8bd0751f6a0a03046c690df60989f2f95c2c/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:de7c42f897e689ee6f9e93c4bec72b99ae3b32a2ade1c7e4798e690ff5246e02", size = 2051629 }, - { url = "https://files.pythonhosted.org/packages/68/de/45af3ca2f175d91b96bfb62e1f2d2f1f9f3b14a734afe0bfeff079f78181/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:664b3199193262277b8b3cd1e754fb07f2c6023289c815a1e1e8fb415cb247b1", size = 2224049 }, - { url = "https://files.pythonhosted.org/packages/af/8f/ae4e1ff84672bf869d0a77af24fd78387850e9497753c432875066b5d622/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d95b253b88f7d308b1c0b417c4624f44553ba4762816f94e6986819b9c273fb2", size = 2342409 }, - { url = "https://files.pythonhosted.org/packages/18/62/273dd70b0026a085c7b74b000394e1ef95719ea579c76ea2f0cc8893736d/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1351f5bbdbbabc689727cb91649a00cb9ee7203e0a6e54e9f5ba9e22e384b84", size = 2069635 }, - { url = "https://files.pythonhosted.org/packages/30/03/cf485fff699b4cdaea469bc481719d3e49f023241b4abb656f8d422189fc/pydantic_core-2.41.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1affa4798520b148d7182da0615d648e752de4ab1a9566b7471bc803d88a062d", size = 2194284 }, - { url = "https://files.pythonhosted.org/packages/f9/7e/c8e713db32405dfd97211f2fc0a15d6bf8adb7640f3d18544c1f39526619/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7b74e18052fea4aa8dea2fb7dbc23d15439695da6cbe6cfc1b694af1115df09d", size = 2137566 }, - { url = "https://files.pythonhosted.org/packages/04/f7/db71fd4cdccc8b75990f79ccafbbd66757e19f6d5ee724a6252414483fb4/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:285b643d75c0e30abda9dc1077395624f314a37e3c09ca402d4015ef5979f1a2", size = 2316809 }, - { url = "https://files.pythonhosted.org/packages/76/63/a54973ddb945f1bca56742b48b144d85c9fc22f819ddeb9f861c249d5464/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f52679ff4218d713b3b33f88c89ccbf3a5c2c12ba665fb80ccc4192b4608dbab", size = 2311119 }, - { url = "https://files.pythonhosted.org/packages/f8/03/5d12891e93c19218af74843a27e32b94922195ded2386f7b55382f904d2f/pydantic_core-2.41.4-cp313-cp313-win32.whl", hash = "sha256:ecde6dedd6fff127c273c76821bb754d793be1024bc33314a120f83a3c69460c", size = 1981398 }, - { url = "https://files.pythonhosted.org/packages/be/d8/fd0de71f39db91135b7a26996160de71c073d8635edfce8b3c3681be0d6d/pydantic_core-2.41.4-cp313-cp313-win_amd64.whl", hash = "sha256:d081a1f3800f05409ed868ebb2d74ac39dd0c1ff6c035b5162356d76030736d4", size = 2030735 }, - { url = "https://files.pythonhosted.org/packages/72/86/c99921c1cf6650023c08bfab6fe2d7057a5142628ef7ccfa9921f2dda1d5/pydantic_core-2.41.4-cp313-cp313-win_arm64.whl", hash = "sha256:f8e49c9c364a7edcbe2a310f12733aad95b022495ef2a8d653f645e5d20c1564", size = 1973209 }, - { url = "https://files.pythonhosted.org/packages/36/0d/b5706cacb70a8414396efdda3d72ae0542e050b591119e458e2490baf035/pydantic_core-2.41.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ed97fd56a561f5eb5706cebe94f1ad7c13b84d98312a05546f2ad036bafe87f4", size = 1877324 }, - { url = "https://files.pythonhosted.org/packages/de/2d/cba1fa02cfdea72dfb3a9babb067c83b9dff0bbcb198368e000a6b756ea7/pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a870c307bf1ee91fc58a9a61338ff780d01bfae45922624816878dce784095d2", size = 1884515 }, - { url = "https://files.pythonhosted.org/packages/07/ea/3df927c4384ed9b503c9cc2d076cf983b4f2adb0c754578dfb1245c51e46/pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d25e97bc1f5f8f7985bdc2335ef9e73843bb561eb1fa6831fdfc295c1c2061cf", size = 2042819 }, - { url = "https://files.pythonhosted.org/packages/6a/ee/df8e871f07074250270a3b1b82aad4cd0026b588acd5d7d3eb2fcb1471a3/pydantic_core-2.41.4-cp313-cp313t-win_amd64.whl", hash = "sha256:d405d14bea042f166512add3091c1af40437c2e7f86988f3915fabd27b1e9cd2", size = 1995866 }, - { url = "https://files.pythonhosted.org/packages/fc/de/b20f4ab954d6d399499c33ec4fafc46d9551e11dc1858fb7f5dca0748ceb/pydantic_core-2.41.4-cp313-cp313t-win_arm64.whl", hash = "sha256:19f3684868309db5263a11bace3c45d93f6f24afa2ffe75a647583df22a2ff89", size = 1970034 }, - { url = "https://files.pythonhosted.org/packages/54/28/d3325da57d413b9819365546eb9a6e8b7cbd9373d9380efd5f74326143e6/pydantic_core-2.41.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e9205d97ed08a82ebb9a307e92914bb30e18cdf6f6b12ca4bedadb1588a0bfe1", size = 2102022 }, - { url = "https://files.pythonhosted.org/packages/9e/24/b58a1bc0d834bf1acc4361e61233ee217169a42efbdc15a60296e13ce438/pydantic_core-2.41.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:82df1f432b37d832709fbcc0e24394bba04a01b6ecf1ee87578145c19cde12ac", size = 1905495 }, - { url = "https://files.pythonhosted.org/packages/fb/a4/71f759cc41b7043e8ecdaab81b985a9b6cad7cec077e0b92cff8b71ecf6b/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3b4cc4539e055cfa39a3763c939f9d409eb40e85813257dcd761985a108554", size = 1956131 }, - { url = "https://files.pythonhosted.org/packages/b0/64/1e79ac7aa51f1eec7c4cda8cbe456d5d09f05fdd68b32776d72168d54275/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b1eb1754fce47c63d2ff57fdb88c351a6c0150995890088b33767a10218eaa4e", size = 2052236 }, - { url = "https://files.pythonhosted.org/packages/e9/e3/a3ffc363bd4287b80f1d43dc1c28ba64831f8dfc237d6fec8f2661138d48/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6ab5ab30ef325b443f379ddb575a34969c333004fca5a1daa0133a6ffaad616", size = 2223573 }, - { url = "https://files.pythonhosted.org/packages/28/27/78814089b4d2e684a9088ede3790763c64693c3d1408ddc0a248bc789126/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:31a41030b1d9ca497634092b46481b937ff9397a86f9f51bd41c4767b6fc04af", size = 2342467 }, - { url = "https://files.pythonhosted.org/packages/92/97/4de0e2a1159cb85ad737e03306717637842c88c7fd6d97973172fb183149/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a44ac1738591472c3d020f61c6df1e4015180d6262ebd39bf2aeb52571b60f12", size = 2063754 }, - { url = "https://files.pythonhosted.org/packages/0f/50/8cb90ce4b9efcf7ae78130afeb99fd1c86125ccdf9906ef64b9d42f37c25/pydantic_core-2.41.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d72f2b5e6e82ab8f94ea7d0d42f83c487dc159c5240d8f83beae684472864e2d", size = 2196754 }, - { url = "https://files.pythonhosted.org/packages/34/3b/ccdc77af9cd5082723574a1cc1bcae7a6acacc829d7c0a06201f7886a109/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c4d1e854aaf044487d31143f541f7aafe7b482ae72a022c664b2de2e466ed0ad", size = 2137115 }, - { url = "https://files.pythonhosted.org/packages/ca/ba/e7c7a02651a8f7c52dc2cff2b64a30c313e3b57c7d93703cecea76c09b71/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b568af94267729d76e6ee5ececda4e283d07bbb28e8148bb17adad93d025d25a", size = 2317400 }, - { url = "https://files.pythonhosted.org/packages/2c/ba/6c533a4ee8aec6b812c643c49bb3bd88d3f01e3cebe451bb85512d37f00f/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6d55fb8b1e8929b341cc313a81a26e0d48aa3b519c1dbaadec3a6a2b4fcad025", size = 2312070 }, - { url = "https://files.pythonhosted.org/packages/22/ae/f10524fcc0ab8d7f96cf9a74c880243576fd3e72bd8ce4f81e43d22bcab7/pydantic_core-2.41.4-cp314-cp314-win32.whl", hash = "sha256:5b66584e549e2e32a1398df11da2e0a7eff45d5c2d9db9d5667c5e6ac764d77e", size = 1982277 }, - { url = "https://files.pythonhosted.org/packages/b4/dc/e5aa27aea1ad4638f0c3fb41132f7eb583bd7420ee63204e2d4333a3bbf9/pydantic_core-2.41.4-cp314-cp314-win_amd64.whl", hash = "sha256:557a0aab88664cc552285316809cab897716a372afaf8efdbef756f8b890e894", size = 2024608 }, - { url = "https://files.pythonhosted.org/packages/3e/61/51d89cc2612bd147198e120a13f150afbf0bcb4615cddb049ab10b81b79e/pydantic_core-2.41.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f1ea6f48a045745d0d9f325989d8abd3f1eaf47dd00485912d1a3a63c623a8d", size = 1967614 }, - { url = "https://files.pythonhosted.org/packages/0d/c2/472f2e31b95eff099961fa050c376ab7156a81da194f9edb9f710f68787b/pydantic_core-2.41.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6c1fe4c5404c448b13188dd8bd2ebc2bdd7e6727fa61ff481bcc2cca894018da", size = 1876904 }, - { url = "https://files.pythonhosted.org/packages/4a/07/ea8eeb91173807ecdae4f4a5f4b150a520085b35454350fc219ba79e66a3/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:523e7da4d43b113bf8e7b49fa4ec0c35bf4fe66b2230bfc5c13cc498f12c6c3e", size = 1882538 }, - { url = "https://files.pythonhosted.org/packages/1e/29/b53a9ca6cd366bfc928823679c6a76c7a4c69f8201c0ba7903ad18ebae2f/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5729225de81fb65b70fdb1907fcf08c75d498f4a6f15af005aabb1fdadc19dfa", size = 2041183 }, - { url = "https://files.pythonhosted.org/packages/c7/3d/f8c1a371ceebcaf94d6dd2d77c6cf4b1c078e13a5837aee83f760b4f7cfd/pydantic_core-2.41.4-cp314-cp314t-win_amd64.whl", hash = "sha256:de2cfbb09e88f0f795fd90cf955858fc2c691df65b1f21f0aa00b99f3fbc661d", size = 1993542 }, - { url = "https://files.pythonhosted.org/packages/8a/ac/9fc61b4f9d079482a290afe8d206b8f490e9fd32d4fc03ed4fc698214e01/pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0", size = 1973897 }, - { url = "https://files.pythonhosted.org/packages/c4/48/ae937e5a831b7c0dc646b2ef788c27cd003894882415300ed21927c21efa/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:4f5d640aeebb438517150fdeec097739614421900e4a08db4a3ef38898798537", size = 2112087 }, - { url = "https://files.pythonhosted.org/packages/5e/db/6db8073e3d32dae017da7e0d16a9ecb897d0a4d92e00634916e486097961/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:4a9ab037b71927babc6d9e7fc01aea9e66dc2a4a34dff06ef0724a4049629f94", size = 1920387 }, - { url = "https://files.pythonhosted.org/packages/0d/c1/dd3542d072fcc336030d66834872f0328727e3b8de289c662faa04aa270e/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4dab9484ec605c3016df9ad4fd4f9a390bc5d816a3b10c6550f8424bb80b18c", size = 1951495 }, - { url = "https://files.pythonhosted.org/packages/2b/c6/db8d13a1f8ab3f1eb08c88bd00fd62d44311e3456d1e85c0e59e0a0376e7/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8a5028425820731d8c6c098ab642d7b8b999758e24acae03ed38a66eca8335", size = 2139008 }, +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] [[package]] name = "pygments" version = "2.19.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631 } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 }, + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] [[package]] @@ -853,38 +1171,54 @@ dependencies = [ { name = "markdown" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/b3/6d2b3f149bc5413b0a29761c2c5832d8ce904a1d7f621e86616d96f505cc/pymdown_extensions-10.16.1.tar.gz", hash = "sha256:aace82bcccba3efc03e25d584e6a22d27a8e17caa3f4dd9f207e49b787aa9a91", size = 853277 } +sdist = { url = "https://files.pythonhosted.org/packages/55/b3/6d2b3f149bc5413b0a29761c2c5832d8ce904a1d7f621e86616d96f505cc/pymdown_extensions-10.16.1.tar.gz", hash = "sha256:aace82bcccba3efc03e25d584e6a22d27a8e17caa3f4dd9f207e49b787aa9a91", size = 853277, upload-time = "2025-07-28T16:19:34.167Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/06/43084e6cbd4b3bc0e80f6be743b2e79fbc6eed8de9ad8c629939fa55d972/pymdown_extensions-10.16.1-py3-none-any.whl", hash = "sha256:d6ba157a6c03146a7fb122b2b9a121300056384eafeec9c9f9e584adfdb2a32d", size = 266178, upload-time = "2025-07-28T16:19:31.401Z" }, +] + +[[package]] +name = "pyproject-api" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/7b/c0e1333b61d41c69e59e5366e727b18c4992688caf0de1be10b3e5265f6b/pyproject_api-1.10.0.tar.gz", hash = "sha256:40c6f2d82eebdc4afee61c773ed208c04c19db4c4a60d97f8d7be3ebc0bbb330", size = 22785, upload-time = "2025-10-09T19:12:27.21Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/06/43084e6cbd4b3bc0e80f6be743b2e79fbc6eed8de9ad8c629939fa55d972/pymdown_extensions-10.16.1-py3-none-any.whl", hash = "sha256:d6ba157a6c03146a7fb122b2b9a121300056384eafeec9c9f9e584adfdb2a32d", size = 266178 }, + { url = "https://files.pythonhosted.org/packages/54/cc/cecf97be298bee2b2a37dd360618c819a2a7fd95251d8e480c1f0eb88f3b/pyproject_api-1.10.0-py3-none-any.whl", hash = "sha256:8757c41a79c0f4ab71b99abed52b97ecf66bd20b04fa59da43b5840bac105a09", size = 13218, upload-time = "2025-10-09T19:12:24.428Z" }, ] [[package]] name = "pytest" -version = "8.4.2" +version = "9.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618 } +sdist = { url = "https://files.pythonhosted.org/packages/da/1d/eb34f286b164c5e431a810a38697409cca1112cee04b287bb56ac486730b/pytest-9.0.0.tar.gz", hash = "sha256:8f44522eafe4137b0f35c9ce3072931a788a21ee40a2ed279e817d3cc16ed21e", size = 1562764, upload-time = "2025-11-08T17:25:33.34Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750 }, + { url = "https://files.pythonhosted.org/packages/72/99/cafef234114a3b6d9f3aaed0723b437c40c57bdb7b3e4c3a575bc4890052/pytest-9.0.0-py3-none-any.whl", hash = "sha256:e5ccdf10b0bac554970ee88fc1a4ad0ee5d221f8ef22321f9b7e4584e19d7f96", size = 373364, upload-time = "2025-11-08T17:25:31.811Z" }, ] [[package]] name = "pytest-asyncio" -version = "1.2.0" +version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, { name = "pytest" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119 } +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095 }, + { 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]] @@ -894,64 +1228,82 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] [[package]] name = "pytz" version = "2025.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884 } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225 }, + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, ] [[package]] name = "pyyaml" version = "6.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063 }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973 }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116 }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011 }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870 }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089 }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181 }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 }, - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 }, +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] [[package]] @@ -961,9 +1313,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737 } +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722 }, + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, ] [[package]] @@ -976,75 +1328,160 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517 } +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738 }, + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] [[package]] name = "ruff" -version = "0.14.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9e/58/6ca66896635352812de66f71cdf9ff86b3a4f79071ca5730088c0cd0fc8d/ruff-0.14.1.tar.gz", hash = "sha256:1dd86253060c4772867c61791588627320abcb6ed1577a90ef432ee319729b69", size = 5513429 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/39/9cc5ab181478d7a18adc1c1e051a84ee02bec94eb9bdfd35643d7c74ca31/ruff-0.14.1-py3-none-linux_armv6l.whl", hash = "sha256:083bfc1f30f4a391ae09c6f4f99d83074416b471775b59288956f5bc18e82f8b", size = 12445415 }, - { url = "https://files.pythonhosted.org/packages/ef/2e/1226961855ccd697255988f5a2474890ac7c5863b080b15bd038df820818/ruff-0.14.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f6fa757cd717f791009f7669fefb09121cc5f7d9bd0ef211371fad68c2b8b224", size = 12784267 }, - { url = "https://files.pythonhosted.org/packages/c1/ea/fd9e95863124ed159cd0667ec98449ae461de94acda7101f1acb6066da00/ruff-0.14.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d6191903d39ac156921398e9c86b7354d15e3c93772e7dbf26c9fcae59ceccd5", size = 11781872 }, - { url = "https://files.pythonhosted.org/packages/1e/5a/e890f7338ff537dba4589a5e02c51baa63020acfb7c8cbbaea4831562c96/ruff-0.14.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed04f0e04f7a4587244e5c9d7df50e6b5bf2705d75059f409a6421c593a35896", size = 12226558 }, - { url = "https://files.pythonhosted.org/packages/a6/7a/8ab5c3377f5bf31e167b73651841217542bcc7aa1c19e83030835cc25204/ruff-0.14.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5c9e6cf6cd4acae0febbce29497accd3632fe2025c0c583c8b87e8dbdeae5f61", size = 12187898 }, - { url = "https://files.pythonhosted.org/packages/48/8d/ba7c33aa55406955fc124e62c8259791c3d42e3075a71710fdff9375134f/ruff-0.14.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a6fa2458527794ecdfbe45f654e42c61f2503a230545a91af839653a0a93dbc6", size = 12939168 }, - { url = "https://files.pythonhosted.org/packages/b4/c2/70783f612b50f66d083380e68cbd1696739d88e9b4f6164230375532c637/ruff-0.14.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:39f1c392244e338b21d42ab29b8a6392a722c5090032eb49bb4d6defcdb34345", size = 14386942 }, - { url = "https://files.pythonhosted.org/packages/48/44/cd7abb9c776b66d332119d67f96acf15830d120f5b884598a36d9d3f4d83/ruff-0.14.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7382fa12a26cce1f95070ce450946bec357727aaa428983036362579eadcc5cf", size = 13990622 }, - { url = "https://files.pythonhosted.org/packages/eb/56/4259b696db12ac152fe472764b4f78bbdd9b477afd9bc3a6d53c01300b37/ruff-0.14.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd0bf2be3ae8521e1093a487c4aa3b455882f139787770698530d28ed3fbb37c", size = 13431143 }, - { url = "https://files.pythonhosted.org/packages/e0/35/266a80d0eb97bd224b3265b9437bd89dde0dcf4faf299db1212e81824e7e/ruff-0.14.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cabcaa9ccf8089fb4fdb78d17cc0e28241520f50f4c2e88cb6261ed083d85151", size = 13132844 }, - { url = "https://files.pythonhosted.org/packages/65/6e/d31ce218acc11a8d91ef208e002a31acf315061a85132f94f3df7a252b18/ruff-0.14.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:747d583400f6125ec11a4c14d1c8474bf75d8b419ad22a111a537ec1a952d192", size = 13401241 }, - { url = "https://files.pythonhosted.org/packages/9f/b5/dbc4221bf0b03774b3b2f0d47f39e848d30664157c15b965a14d890637d2/ruff-0.14.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5a6e74c0efd78515a1d13acbfe6c90f0f5bd822aa56b4a6d43a9ffb2ae6e56cd", size = 12132476 }, - { url = "https://files.pythonhosted.org/packages/98/4b/ac99194e790ccd092d6a8b5f341f34b6e597d698e3077c032c502d75ea84/ruff-0.14.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0ea6a864d2fb41a4b6d5b456ed164302a0d96f4daac630aeba829abfb059d020", size = 12139749 }, - { url = "https://files.pythonhosted.org/packages/47/26/7df917462c3bb5004e6fdfcc505a49e90bcd8a34c54a051953118c00b53a/ruff-0.14.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0826b8764f94229604fa255918d1cc45e583e38c21c203248b0bfc9a0e930be5", size = 12544758 }, - { url = "https://files.pythonhosted.org/packages/64/d0/81e7f0648e9764ad9b51dd4be5e5dac3fcfff9602428ccbae288a39c2c22/ruff-0.14.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cbc52160465913a1a3f424c81c62ac8096b6a491468e7d872cb9444a860bc33d", size = 13221811 }, - { url = "https://files.pythonhosted.org/packages/c3/07/3c45562c67933cc35f6d5df4ca77dabbcd88fddaca0d6b8371693d29fd56/ruff-0.14.1-py3-none-win32.whl", hash = "sha256:e037ea374aaaff4103240ae79168c0945ae3d5ae8db190603de3b4012bd1def6", size = 12319467 }, - { url = "https://files.pythonhosted.org/packages/02/88/0ee4ca507d4aa05f67e292d2e5eb0b3e358fbcfe527554a2eda9ac422d6b/ruff-0.14.1-py3-none-win_amd64.whl", hash = "sha256:59d599cdff9c7f925a017f6f2c256c908b094e55967f93f2821b1439928746a1", size = 13401123 }, - { url = "https://files.pythonhosted.org/packages/b8/81/4b6387be7014858d924b843530e1b2a8e531846807516e9bea2ee0936bf7/ruff-0.14.1-py3-none-win_arm64.whl", hash = "sha256:e3b443c4c9f16ae850906b8d0a707b2a4c16f8d2f0a7fe65c475c5886665ce44", size = 12436636 }, +version = "0.14.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/55/cccfca45157a2031dcbb5a462a67f7cf27f8b37d4b3b1cd7438f0f5c1df6/ruff-0.14.4.tar.gz", hash = "sha256:f459a49fe1085a749f15414ca76f61595f1a2cc8778ed7c279b6ca2e1fd19df3", size = 5587844, upload-time = "2025-11-06T22:07:45.033Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/b9/67240254166ae1eaa38dec32265e9153ac53645a6c6670ed36ad00722af8/ruff-0.14.4-py3-none-linux_armv6l.whl", hash = "sha256:e6604613ffbcf2297cd5dcba0e0ac9bd0c11dc026442dfbb614504e87c349518", size = 12606781, upload-time = "2025-11-06T22:07:01.841Z" }, + { url = "https://files.pythonhosted.org/packages/46/c8/09b3ab245d8652eafe5256ab59718641429f68681ee713ff06c5c549f156/ruff-0.14.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d99c0b52b6f0598acede45ee78288e5e9b4409d1ce7f661f0fa36d4cbeadf9a4", size = 12946765, upload-time = "2025-11-06T22:07:05.858Z" }, + { url = "https://files.pythonhosted.org/packages/14/bb/1564b000219144bf5eed2359edc94c3590dd49d510751dad26202c18a17d/ruff-0.14.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9358d490ec030f1b51d048a7fd6ead418ed0826daf6149e95e30aa67c168af33", size = 11928120, upload-time = "2025-11-06T22:07:08.023Z" }, + { url = "https://files.pythonhosted.org/packages/a3/92/d5f1770e9988cc0742fefaa351e840d9aef04ec24ae1be36f333f96d5704/ruff-0.14.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81b40d27924f1f02dfa827b9c0712a13c0e4b108421665322218fc38caf615c2", size = 12370877, upload-time = "2025-11-06T22:07:10.015Z" }, + { url = "https://files.pythonhosted.org/packages/e2/29/e9282efa55f1973d109faf839a63235575519c8ad278cc87a182a366810e/ruff-0.14.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f5e649052a294fe00818650712083cddc6cc02744afaf37202c65df9ea52efa5", size = 12408538, upload-time = "2025-11-06T22:07:13.085Z" }, + { url = "https://files.pythonhosted.org/packages/8e/01/930ed6ecfce130144b32d77d8d69f5c610e6d23e6857927150adf5d7379a/ruff-0.14.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa082a8f878deeba955531f975881828fd6afd90dfa757c2b0808aadb437136e", size = 13141942, upload-time = "2025-11-06T22:07:15.386Z" }, + { url = "https://files.pythonhosted.org/packages/6a/46/a9c89b42b231a9f487233f17a89cbef9d5acd538d9488687a02ad288fa6b/ruff-0.14.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1043c6811c2419e39011890f14d0a30470f19d47d197c4858b2787dfa698f6c8", size = 14544306, upload-time = "2025-11-06T22:07:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/78/96/9c6cf86491f2a6d52758b830b89b78c2ae61e8ca66b86bf5a20af73d20e6/ruff-0.14.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a9f3a936ac27fb7c2a93e4f4b943a662775879ac579a433291a6f69428722649", size = 14210427, upload-time = "2025-11-06T22:07:19.832Z" }, + { url = "https://files.pythonhosted.org/packages/71/f4/0666fe7769a54f63e66404e8ff698de1dcde733e12e2fd1c9c6efb689cb5/ruff-0.14.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:95643ffd209ce78bc113266b88fba3d39e0461f0cbc8b55fb92505030fb4a850", size = 13658488, upload-time = "2025-11-06T22:07:22.32Z" }, + { url = "https://files.pythonhosted.org/packages/ee/79/6ad4dda2cfd55e41ac9ed6d73ef9ab9475b1eef69f3a85957210c74ba12c/ruff-0.14.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:456daa2fa1021bc86ca857f43fe29d5d8b3f0e55e9f90c58c317c1dcc2afc7b5", size = 13354908, upload-time = "2025-11-06T22:07:24.347Z" }, + { url = "https://files.pythonhosted.org/packages/b5/60/f0b6990f740bb15c1588601d19d21bcc1bd5de4330a07222041678a8e04f/ruff-0.14.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:f911bba769e4a9f51af6e70037bb72b70b45a16db5ce73e1f72aefe6f6d62132", size = 13587803, upload-time = "2025-11-06T22:07:26.327Z" }, + { url = "https://files.pythonhosted.org/packages/c9/da/eaaada586f80068728338e0ef7f29ab3e4a08a692f92eb901a4f06bbff24/ruff-0.14.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:76158a7369b3979fa878612c623a7e5430c18b2fd1c73b214945c2d06337db67", size = 12279654, upload-time = "2025-11-06T22:07:28.46Z" }, + { url = "https://files.pythonhosted.org/packages/66/d4/b1d0e82cf9bf8aed10a6d45be47b3f402730aa2c438164424783ac88c0ed/ruff-0.14.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f3b8f3b442d2b14c246e7aeca2e75915159e06a3540e2f4bed9f50d062d24469", size = 12357520, upload-time = "2025-11-06T22:07:31.468Z" }, + { url = "https://files.pythonhosted.org/packages/04/f4/53e2b42cc82804617e5c7950b7079d79996c27e99c4652131c6a1100657f/ruff-0.14.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c62da9a06779deecf4d17ed04939ae8b31b517643b26370c3be1d26f3ef7dbde", size = 12719431, upload-time = "2025-11-06T22:07:33.831Z" }, + { url = "https://files.pythonhosted.org/packages/a2/94/80e3d74ed9a72d64e94a7b7706b1c1ebaa315ef2076fd33581f6a1cd2f95/ruff-0.14.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5a443a83a1506c684e98acb8cb55abaf3ef725078be40237463dae4463366349", size = 13464394, upload-time = "2025-11-06T22:07:35.905Z" }, + { url = "https://files.pythonhosted.org/packages/54/1a/a49f071f04c42345c793d22f6cf5e0920095e286119ee53a64a3a3004825/ruff-0.14.4-py3-none-win32.whl", hash = "sha256:643b69cb63cd996f1fc7229da726d07ac307eae442dd8974dbc7cf22c1e18fff", size = 12493429, upload-time = "2025-11-06T22:07:38.43Z" }, + { url = "https://files.pythonhosted.org/packages/bc/22/e58c43e641145a2b670328fb98bc384e20679b5774258b1e540207580266/ruff-0.14.4-py3-none-win_amd64.whl", hash = "sha256:26673da283b96fe35fa0c939bf8411abec47111644aa9f7cfbd3c573fb125d2c", size = 13635380, upload-time = "2025-11-06T22:07:40.496Z" }, + { url = "https://files.pythonhosted.org/packages/30/bd/4168a751ddbbf43e86544b4de8b5c3b7be8d7167a2a5cb977d274e04f0a1/ruff-0.14.4-py3-none-win_arm64.whl", hash = "sha256:dd09c292479596b0e6fec8cd95c65c3a6dc68e9ad17b8f2382130f87ff6a75bb", size = 12663065, upload-time = "2025-11-06T22:07:42.603Z" }, ] [[package]] name = "six" version = "1.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] [[package]] name = "sniffio" version = "1.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] [[package]] name = "starlette" -version = "0.48.0" +version = "0.38.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a7/a5/d6f429d43394057b67a6b5bbe6eae2f77a6bf7459d961fdb224bf206eee6/starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46", size = 2652949 } +sdist = { url = "https://files.pythonhosted.org/packages/42/b4/e25c3b688ef703d85e55017c6edd0cbf38e5770ab748234363d54ff0251a/starlette-0.38.6.tar.gz", hash = "sha256:863a1588f5574e70a821dadefb41e4881ea451a47a3cd1b4df359d4ffefe5ead", size = 2569491, upload-time = "2024-09-22T17:01:45.422Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/9c/93f7bc03ff03199074e81974cc148908ead60dcf189f68ba1761a0ee35cf/starlette-0.38.6-py3-none-any.whl", hash = "sha256:4517a1409e2e73ee4951214ba012052b9e16f60e90d73cfb06192c19203bbb05", size = 71451, upload-time = "2024-09-22T17:01:43.076Z" }, +] + +[[package]] +name = "tomli" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/72/2db2f49247d0a18b4f1bb9a5a39a0162869acf235f3a96418363947b3d46/starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659", size = 73736 }, + { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, + { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, + { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, + { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, + { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, + { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, + { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, + { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, + { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, + { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, + { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, + { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, + { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, + { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, + { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, + { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, + { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, + { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, + { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, + { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, +] + +[[package]] +name = "tox" +version = "4.32.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachetools" }, + { name = "chardet" }, + { name = "colorama" }, + { name = "filelock" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "pluggy" }, + { name = "pyproject-api" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/bf/0e4dbd42724cbae25959f0e34c95d0c730df03ab03f54d52accd9abfc614/tox-4.32.0.tar.gz", hash = "sha256:1ad476b5f4d3679455b89a992849ffc3367560bbc7e9495ee8a3963542e7c8ff", size = 203330, upload-time = "2025-10-24T18:03:38.132Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/cc/e09c0d663a004945f82beecd4f147053567910479314e8d01ba71e5d5dea/tox-4.32.0-py3-none-any.whl", hash = "sha256:451e81dc02ba8d1ed20efd52ee409641ae4b5d5830e008af10fe8823ef1bd551", size = 175905, upload-time = "2025-10-24T18:03:36.337Z" }, +] + +[[package]] +name = "tox-uv" +version = "1.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "tox" }, + { name = "uv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4f/90/06752775b8cfadba8856190f5beae9f552547e0f287e0246677972107375/tox_uv-1.29.0.tar.gz", hash = "sha256:30fa9e6ad507df49d3c6a2f88894256bcf90f18e240a00764da6ecab1db24895", size = 23427, upload-time = "2025-10-09T20:40:27.384Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/17/221d62937c4130b044bb437caac4181e7e13d5536bbede65264db1f0ac9f/tox_uv-1.29.0-py3-none-any.whl", hash = "sha256:b1d251286edeeb4bc4af1e24c8acfdd9404700143c2199ccdbb4ea195f7de6cc", size = 17254, upload-time = "2025-10-09T20:40:25.885Z" }, ] [[package]] name = "typing-extensions" version = "4.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] [[package]] @@ -1054,49 +1491,98 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 } +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 }, + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] [[package]] name = "tzdata" version = "2025.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380 } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839 }, + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, ] [[package]] name = "urllib3" version = "2.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185 } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795 }, + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[package]] +name = "uv" +version = "0.9.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/78/291b32fdcc774b8ba4a0f4570af44af6cd34ef7385537d6521c7e3280030/uv-0.9.8.tar.gz", hash = "sha256:99b18bfe92c33c3862b65d74677697e799763e669e0064685f405e7e27517f25", size = 3709979, upload-time = "2025-11-07T20:41:33.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/5d/4db5a4e72f70e15491ca33289092cd127d1220861bc647ebf743ea844cd7/uv-0.9.8-py3-none-linux_armv6l.whl", hash = "sha256:d93a2227d23e81ab3a16c30363559afc483e8aca40ea9343b3f326a9a41718c9", size = 20566439, upload-time = "2025-11-07T20:40:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/e6/76/3ffedb2ba3adf71719996cb4c2660a333d2267503823a02e184a839e1d4e/uv-0.9.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7038a552159f2291dd0d1f4f66a36261b5f3ed5fcd92e2869186f8e910b2c935", size = 19705224, upload-time = "2025-11-07T20:40:31.384Z" }, + { url = "https://files.pythonhosted.org/packages/da/37/7716dd87189a6b062502ea41650eccd2473b6ee54b37cdf6e90a3b1aaa17/uv-0.9.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9f2f3576c4518ff4f15e48dbca70585a513523c4738bc8cc2e48b20fd1190ce3", size = 18213823, upload-time = "2025-11-07T20:40:34.962Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ed/7aa302fac3d6c880df6bdbba3fb6b4d8cded023b1398f99576dcb103051a/uv-0.9.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:50d130c46d97d7f10675ebea8608b7b4722c84b5745cd1bb0c8ae6d7984c05d5", size = 20090145, upload-time = "2025-11-07T20:40:38.842Z" }, + { url = "https://files.pythonhosted.org/packages/72/d2/2539fe7ecf03f5fa3dfcc4c39f59ade412bd1b8e89c9ae026b5a2d7da3dd/uv-0.9.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6df2e16f6df32018047c60bab2c0284868ad5c309addba9183ea2eeb71746bf0", size = 20218906, upload-time = "2025-11-07T20:40:42.189Z" }, + { url = "https://files.pythonhosted.org/packages/f7/29/2923cd822b9a1dc9b99513a00d2102c7ef979ac3001e9541e72a1e7fca07/uv-0.9.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:543693def38fa41b9706aba391111fe8d9dd6be86899d76f9581faf045ac1cb6", size = 21061669, upload-time = "2025-11-07T20:40:47.663Z" }, + { url = "https://files.pythonhosted.org/packages/72/c6/46b9fe190e6fafb6bf04d870ccfd547e69aa79d0448a5c2c5799f1c0850e/uv-0.9.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1b8b5bdcda3e10ea70b618d0609acddc5c725cb58d4caf933030ddedd7c2e98f", size = 22668783, upload-time = "2025-11-07T20:40:51.172Z" }, + { url = "https://files.pythonhosted.org/packages/94/80/ec48165c76f863bbfcb0721aa1543cd3e7209c0cb8fdf89fe3d4e16694e2/uv-0.9.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a4010b3fdabbb3c4f2cf2f7aa3bf6002d00049dcbc54ce0ee5ada32a933b2290", size = 22319178, upload-time = "2025-11-07T20:40:54.719Z" }, + { url = "https://files.pythonhosted.org/packages/33/6c/2dbda528a2cd7a87a7363e8a9aad3033bff12c8b071a5e462eb852e704fd/uv-0.9.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75671150d6eb9d5ee829e1fdb8cf86b8e44a66d27cbb996fe807e986c4107b5d", size = 21398576, upload-time = "2025-11-07T20:40:58.509Z" }, + { url = "https://files.pythonhosted.org/packages/90/66/07e7067ace0886212217380b6e809f7dd1fed3d35c34be8d02124a656b17/uv-0.9.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14670bf55ecb5cfd0f3654fbf51c58a21dec3ad8ab531079b3ed8599271dc77b", size = 21346696, upload-time = "2025-11-07T20:41:01.931Z" }, + { url = "https://files.pythonhosted.org/packages/35/98/5b8fad804d17e76a2861c932009b0d34c7d5e3517923a808b168c2d92f2b/uv-0.9.8-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:40253d00c1e900a0a61b132b1e0dd4aa83575cfd5302d3671899b6de29b1ef67", size = 20159753, upload-time = "2025-11-07T20:41:05.51Z" }, + { url = "https://files.pythonhosted.org/packages/5d/e4/32b74e9246e71f27b8710ba44be6bfd8bdcf552dce211cecd4d1061705cc/uv-0.9.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:f52c6a99197028a314d4c1825f7ccb696eb9a88b822d2e2f17046266c75e543e", size = 21299928, upload-time = "2025-11-07T20:41:09.285Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/003035bc2da31cc9925a62b1510a821d701c117cf0327ab0a1df5c83db34/uv-0.9.8-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:5af28f1645eb3c50fd34a78508792db2d0799816f4eb5f55e1e6e2c724dfb125", size = 20170593, upload-time = "2025-11-07T20:41:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/d7/b4/8c3d7afdc87ef07b51b87646a4c75ee5209b7f9f99a33d54746b7ee0f157/uv-0.9.8-py3-none-musllinux_1_1_i686.whl", hash = "sha256:cdbfadca9522422ab9820f5ada071c9c5c869bcd6fee719d20d91d5ec85b2a7d", size = 20560556, upload-time = "2025-11-07T20:41:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/64/43/6045bb0b69c788620df4750de57319f56a9b5bd02eef56f28af0de25c117/uv-0.9.8-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:87c3b65b6d5fcbdeab199d54c74fbf75de19cb534a690c936c5616478a038576", size = 21530469, upload-time = "2025-11-07T20:41:20.336Z" }, + { url = "https://files.pythonhosted.org/packages/96/a4/8bb8dca265df52abc405161f918225fbf156fc3a16f380a382a5cd52f992/uv-0.9.8-py3-none-win32.whl", hash = "sha256:0f03bc413c933dbf850ad0dc2dba3df6b80c860a5c65cd767add49da19dadef0", size = 19440191, upload-time = "2025-11-07T20:41:23.612Z" }, + { url = "https://files.pythonhosted.org/packages/6c/b6/9a2ed2c1cc86b967de82c20aeee2860f8771adbcf010061359f5406a6bed/uv-0.9.8-py3-none-win_amd64.whl", hash = "sha256:6a01d7cd41953ffac583139b10ad1df004a67c0246a6b694eb5bcdbc8c99deaf", size = 21491715, upload-time = "2025-11-07T20:41:27.181Z" }, + { url = "https://files.pythonhosted.org/packages/95/77/4a8f429c8d89a17a5327e7be8a7f3b72f7422b0acccfc378d424ca6dc0c9/uv-0.9.8-py3-none-win_arm64.whl", hash = "sha256:bb0f8e83c2a2fc5a802e930cc8a7b71ab068180300a3f27ba38037f9fcb3d430", size = 19865491, upload-time = "2025-11-07T20:41:30.62Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.35.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" }, ] [[package]] name = "watchdog" version = "6.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471 }, - { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449 }, - { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054 }, - { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480 }, - { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451 }, - { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057 }, - { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079 }, - { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078 }, - { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076 }, - { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077 }, - { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078 }, - { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077 }, - { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078 }, - { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065 }, - { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070 }, - { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067 }, +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, ]