diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f2e140bb..6b347d90 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -12,7 +12,7 @@ // Use 'forwardPorts' to make a list of ports inside the container available locally. // "forwardPorts": [], // Use 'postCreateCommand' to run commands after the container is created. - "postCreateCommand": "uv sync && uv run pre-commit install", + "postCreateCommand": "uv sync --all-extras --dev && uv run pre-commit install", // Configure tool-specific properties. "customizations": { "vscode": { diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index d7f4ae48..cfe52dc4 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 760b7766..d7114068 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v6 diff --git a/README.md b/README.md index b9b9b7c4..3bcaa109 100644 --- a/README.md +++ b/README.md @@ -37,9 +37,10 @@ pip install pyoverkiz import asyncio import time -from pyoverkiz.const import SUPPORTED_SERVERS +from pyoverkiz.auth.credentials import UsernamePasswordCredentials from pyoverkiz.client import OverkizClient -from pyoverkiz.enums import Server +from pyoverkiz.models import Action +from pyoverkiz.enums import Server, OverkizCommand USERNAME = "" PASSWORD = "" @@ -47,7 +48,8 @@ PASSWORD = "" async def main() -> None: async with OverkizClient( - USERNAME, PASSWORD, server=SUPPORTED_SERVERS[Server.SOMFY_EUROPE] + server=Server.SOMFY_EUROPE, + credentials=UsernamePasswordCredentials(USERNAME, PASSWORD), ) as client: try: await client.login() @@ -61,6 +63,19 @@ async def main() -> None: print(f"{device.label} ({device.id}) - {device.controllable_name}") print(f"{device.widget} - {device.ui_class}") + await client.execute_action_group( + actions=[ + Action( + device_url="io://1234-5678-1234/12345678", + commands=[ + Command(name=OverkizCommand.SET_CLOSURE, parameters=[100]) + ] + ) + ], + label="Execution via Python", + # mode=CommandMode.HIGH_PRIORITY + ) + while True: events = await client.fetch_events() print(events) @@ -76,14 +91,11 @@ asyncio.run(main()) ```python import asyncio import time -import aiohttp +from pyoverkiz.auth.credentials import LocalTokenCredentials from pyoverkiz.client import OverkizClient -from pyoverkiz.const import SUPPORTED_SERVERS, OverkizServer -from pyoverkiz.enums import Server +from pyoverkiz.utils import create_local_server_config -USERNAME = "" -PASSWORD = "" LOCAL_GATEWAY = "gateway-xxxx-xxxx-xxxx.local" # or use the IP address of your gateway VERIFY_SSL = True # set verify_ssl to False if you don't use the .local hostname @@ -91,23 +103,10 @@ VERIFY_SSL = True # set verify_ssl to False if you don't use the .local hostnam async def main() -> None: token = "" # generate your token via the Somfy app and include it here - # Local Connection - session = aiohttp.ClientSession( - connector=aiohttp.TCPConnector(verify_ssl=VERIFY_SSL) - ) - async with OverkizClient( - username="", - password="", - token=token, - session=session, + server=create_local_server_config(host=LOCAL_GATEWAY), + credentials=LocalTokenCredentials(token), verify_ssl=VERIFY_SSL, - server=OverkizServer( - name="Somfy TaHoma (local)", - endpoint=f"https://{LOCAL_GATEWAY}:8443/enduser-mobile-web/1/enduserAPI/", - manufacturer="Somfy", - configuration_url=None, - ), ) as client: await client.login() diff --git a/pyoverkiz/action_queue.py b/pyoverkiz/action_queue.py new file mode 100644 index 00000000..118c5245 --- /dev/null +++ b/pyoverkiz/action_queue.py @@ -0,0 +1,265 @@ +"""Action queue for batching multiple action executions into single API calls.""" + +from __future__ import annotations + +import asyncio +import contextlib +from collections.abc import Callable, Coroutine +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pyoverkiz.enums import CommandMode + from pyoverkiz.models import Action + + +class QueuedExecution: + """Represents a queued action execution that will resolve to an exec_id when the batch executes.""" + + def __init__(self) -> None: + """Initialize the queued execution.""" + self._future: asyncio.Future[str] = asyncio.Future() + + def set_result(self, exec_id: str) -> None: + """Set the execution ID result.""" + if not self._future.done(): + self._future.set_result(exec_id) + + def set_exception(self, exception: BaseException) -> None: + """Set an exception if the batch execution failed.""" + if not self._future.done(): + self._future.set_exception(exception) + + def is_done(self) -> bool: + """Check if the execution has completed (either with result or exception).""" + return self._future.done() + + def __await__(self): + """Make this awaitable.""" + return self._future.__await__() + + +class ActionQueue: + """Batches multiple action executions into single API calls. + + When actions are added, they are held for a configurable delay period. + If more actions arrive during this window, they are batched together. + The batch is flushed when: + - The delay timer expires + - The max actions limit is reached + - The command mode changes + - Manual flush is requested + """ + + def __init__( + self, + executor: Callable[ + [list[Action], CommandMode | None, str | None], Coroutine[None, None, str] + ], + delay: float = 0.5, + max_actions: int = 20, + ) -> None: + """Initialize the action queue. + + :param executor: Async function to execute batched actions + :param delay: Seconds to wait before auto-flushing (default 0.5) + :param max_actions: Maximum actions per batch before forced flush (default 20) + """ + self._executor = executor + self._delay = delay + self._max_actions = max_actions + + self._pending_actions: list[Action] = [] + self._pending_mode: CommandMode | None = None + self._pending_label: str | None = None + self._pending_waiters: list[QueuedExecution] = [] + + self._flush_task: asyncio.Task[None] | None = None + self._lock = asyncio.Lock() + + async def add( + self, + actions: list[Action], + mode: CommandMode | None = None, + label: str | None = None, + ) -> QueuedExecution: + """Add actions to the queue. + + :param actions: Actions to queue + :param mode: Command mode (will flush if different from pending mode) + :param label: Label for the action group + :return: QueuedExecution that resolves to exec_id when batch executes + """ + batch_to_execute = None + + async with self._lock: + # If mode or label changes, flush existing queue first + if self._pending_actions and ( + mode != self._pending_mode or label != self._pending_label + ): + batch_to_execute = self._prepare_flush() + + # Add actions to pending queue + self._pending_actions.extend(actions) + self._pending_mode = mode + self._pending_label = label + + # Create waiter for this caller. This waiter is added to the current + # batch being built, even if we flushed a previous batch above due to + # a mode/label change. This ensures the waiter belongs to the batch + # containing the actions we just added. + waiter = QueuedExecution() + self._pending_waiters.append(waiter) + + # If we hit max actions, flush immediately + if len(self._pending_actions) >= self._max_actions: + # Prepare the current batch for flushing (which includes the actions + # we just added). If we already flushed due to mode change, this is + # a second batch. + new_batch = self._prepare_flush() + # Execute the first batch if it exists, then the second + if batch_to_execute: + await self._execute_batch(*batch_to_execute) + batch_to_execute = new_batch + elif self._flush_task is None or self._flush_task.done(): + # Schedule delayed flush if not already scheduled + self._flush_task = asyncio.create_task(self._delayed_flush()) + + # Execute batch outside the lock if we flushed + if batch_to_execute: + await self._execute_batch(*batch_to_execute) + + return waiter + + async def _delayed_flush(self) -> None: + """Wait for the delay period, then flush the queue.""" + waiters: list[QueuedExecution] = [] + try: + await asyncio.sleep(self._delay) + async with self._lock: + if not self._pending_actions: + return + + # Take snapshot and clear state while holding lock + actions = self._pending_actions + mode = self._pending_mode + label = self._pending_label + waiters = self._pending_waiters + + self._pending_actions = [] + self._pending_mode = None + self._pending_label = None + self._pending_waiters = [] + self._flush_task = None + + # Execute outside the lock + try: + exec_id = await self._executor(actions, mode, label) + for waiter in waiters: + waiter.set_result(exec_id) + except Exception as exc: + for waiter in waiters: + waiter.set_exception(exc) + except asyncio.CancelledError as exc: + # Ensure all waiters are notified if this task is cancelled + for waiter in waiters: + waiter.set_exception(exc) + raise + + def _prepare_flush( + self, + ) -> tuple[list[Action], CommandMode | None, str | None, list[QueuedExecution]]: + """Prepare a flush by taking snapshot and clearing state (must be called with lock held). + + Returns a tuple of (actions, mode, label, waiters) that should be executed + outside the lock using _execute_batch(). + """ + if not self._pending_actions: + return ([], None, None, []) + + # Cancel any pending flush task + if self._flush_task and not self._flush_task.done(): + self._flush_task.cancel() + self._flush_task = None + + # Take snapshot of current batch + actions = self._pending_actions + mode = self._pending_mode + label = self._pending_label + waiters = self._pending_waiters + + # Clear pending state + self._pending_actions = [] + self._pending_mode = None + self._pending_label = None + self._pending_waiters = [] + + return (actions, mode, label, waiters) + + async def _execute_batch( + self, + actions: list[Action], + mode: CommandMode | None, + label: str | None, + waiters: list[QueuedExecution], + ) -> None: + """Execute a batch of actions and notify waiters (must be called without lock).""" + if not actions: + return + + try: + exec_id = await self._executor(actions, mode, label) + # Notify all waiters + for waiter in waiters: + waiter.set_result(exec_id) + except Exception as exc: + # Propagate exception to all waiters + for waiter in waiters: + waiter.set_exception(exc) + raise + + async def flush(self) -> None: + """Force flush all pending actions immediately. + + This method forces the queue to execute any pending batched actions + without waiting for the delay timer. The execution results are delivered + to the corresponding QueuedExecution objects returned by add(). + + This method is useful for forcing immediate execution without having to + wait for the delay timer to expire. + """ + batch_to_execute = None + async with self._lock: + if self._pending_actions: + batch_to_execute = self._prepare_flush() + + # Execute outside the lock + if batch_to_execute: + await self._execute_batch(*batch_to_execute) + + def get_pending_count(self) -> int: + """Get the (approximate) number of actions currently waiting in the queue. + + This method does not acquire the internal lock and therefore returns a + best-effort snapshot that may be slightly out of date if the queue is + being modified concurrently by other coroutines. + """ + return len(self._pending_actions) + + async def shutdown(self) -> None: + """Shutdown the queue, flushing any pending actions.""" + batch_to_execute = None + async with self._lock: + if self._flush_task and not self._flush_task.done(): + task = self._flush_task + task.cancel() + self._flush_task = None + # Wait for cancellation to complete + with contextlib.suppress(asyncio.CancelledError): + await task + + if self._pending_actions: + batch_to_execute = self._prepare_flush() + + # Execute outside the lock + if batch_to_execute: + await self._execute_batch(*batch_to_execute) diff --git a/pyoverkiz/auth/__init__.py b/pyoverkiz/auth/__init__.py new file mode 100644 index 00000000..535a2614 --- /dev/null +++ b/pyoverkiz/auth/__init__.py @@ -0,0 +1,24 @@ +"""Authentication module for pyoverkiz.""" + +from __future__ import annotations + +from pyoverkiz.auth.base import AuthContext, AuthStrategy +from pyoverkiz.auth.credentials import ( + Credentials, + LocalTokenCredentials, + RexelOAuthCodeCredentials, + TokenCredentials, + UsernamePasswordCredentials, +) +from pyoverkiz.auth.factory import build_auth_strategy + +__all__ = [ + "AuthContext", + "AuthStrategy", + "Credentials", + "LocalTokenCredentials", + "RexelOAuthCodeCredentials", + "TokenCredentials", + "UsernamePasswordCredentials", + "build_auth_strategy", +] diff --git a/pyoverkiz/auth/base.py b/pyoverkiz/auth/base.py new file mode 100644 index 00000000..f4db7059 --- /dev/null +++ b/pyoverkiz/auth/base.py @@ -0,0 +1,42 @@ +"""Base classes for authentication strategies.""" + +from __future__ import annotations + +import datetime +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Protocol + + +@dataclass(slots=True) +class AuthContext: + """Authentication context holding tokens and expiration.""" + + access_token: str | None = None + refresh_token: str | None = None + expires_at: datetime.datetime | None = None + + def is_expired(self, *, skew_seconds: int = 5) -> bool: + """Check if the access token is expired, considering a skew time.""" + if not self.expires_at: + return False + + return datetime.datetime.now( + datetime.UTC + ) >= self.expires_at - datetime.timedelta(seconds=skew_seconds) + + +class AuthStrategy(Protocol): + """Protocol for authentication strategies.""" + + async def login(self) -> None: + """Perform login to obtain tokens.""" + + async def refresh_if_needed(self) -> bool: + """Refresh tokens if they are expired. Return True if refreshed.""" + + def auth_headers(self, path: str | None = None) -> Mapping[str, str]: + """Generate authentication headers for requests.""" + + async def close(self) -> None: + """Clean up any resources held by the strategy.""" diff --git a/pyoverkiz/auth/credentials.py b/pyoverkiz/auth/credentials.py new file mode 100644 index 00000000..777f950b --- /dev/null +++ b/pyoverkiz/auth/credentials.py @@ -0,0 +1,37 @@ +"""Credentials for authentication strategies.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +class Credentials: + """Marker base class for auth credentials.""" + + +@dataclass(slots=True) +class UsernamePasswordCredentials(Credentials): + """Credentials using username and password.""" + + username: str + password: str + + +@dataclass(slots=True) +class TokenCredentials(Credentials): + """Credentials using an (API) token.""" + + token: str + + +@dataclass(slots=True) +class LocalTokenCredentials(TokenCredentials): + """Credentials using a local API token.""" + + +@dataclass(slots=True) +class RexelOAuthCodeCredentials(Credentials): + """Credentials using Rexel OAuth2 authorization code.""" + + code: str + redirect_uri: str diff --git a/pyoverkiz/auth/factory.py b/pyoverkiz/auth/factory.py new file mode 100644 index 00000000..c0901ae6 --- /dev/null +++ b/pyoverkiz/auth/factory.py @@ -0,0 +1,127 @@ +"""Factory to build authentication strategies based on server and credentials.""" + +from __future__ import annotations + +import ssl + +from aiohttp import ClientSession + +from pyoverkiz.auth.credentials import ( + Credentials, + LocalTokenCredentials, + RexelOAuthCodeCredentials, + TokenCredentials, + UsernamePasswordCredentials, +) +from pyoverkiz.auth.strategies import ( + AuthStrategy, + BearerTokenAuthStrategy, + CozytouchAuthStrategy, + LocalTokenAuthStrategy, + NexityAuthStrategy, + RexelAuthStrategy, + SessionLoginStrategy, + SomfyAuthStrategy, +) +from pyoverkiz.enums import APIType, Server +from pyoverkiz.models import ServerConfig + + +def build_auth_strategy( + *, + server_config: ServerConfig, + credentials: Credentials, + session: ClientSession, + ssl_context: ssl.SSLContext | bool, +) -> AuthStrategy: + """Build the correct auth strategy for the given server and credentials.""" + server: Server | None = server_config.server + + if server == Server.SOMFY_EUROPE: + return SomfyAuthStrategy( + _ensure_username_password(credentials), + session, + server_config, + ssl_context, + server_config.type, + ) + + if server in { + Server.ATLANTIC_COZYTOUCH, + Server.THERMOR_COZYTOUCH, + Server.SAUTER_COZYTOUCH, + }: + return CozytouchAuthStrategy( + _ensure_username_password(credentials), + session, + server_config, + ssl_context, + server_config.type, + ) + + if server == Server.NEXITY: + return NexityAuthStrategy( + _ensure_username_password(credentials), + session, + server_config, + ssl_context, + server_config.type, + ) + + if server == Server.REXEL: + return RexelAuthStrategy( + _ensure_rexel(credentials), + session, + server_config, + ssl_context, + server_config.type, + ) + + if server_config.type == APIType.LOCAL: + if isinstance(credentials, LocalTokenCredentials): + return LocalTokenAuthStrategy( + credentials, session, server_config, ssl_context, server_config.type + ) + return BearerTokenAuthStrategy( + _ensure_token(credentials), + session, + server_config, + ssl_context, + server_config.type, + ) + + if isinstance(credentials, TokenCredentials) and not isinstance( + credentials, LocalTokenCredentials + ): + return BearerTokenAuthStrategy( + credentials, session, server_config, ssl_context, server_config.type + ) + + return SessionLoginStrategy( + _ensure_username_password(credentials), + session, + server_config, + ssl_context, + server_config.type, + ) + + +def _ensure_username_password(credentials: Credentials) -> UsernamePasswordCredentials: + """Validate that credentials are username/password based.""" + if not isinstance(credentials, UsernamePasswordCredentials): + raise TypeError("UsernamePasswordCredentials are required for this server.") + return credentials + + +def _ensure_token(credentials: Credentials) -> TokenCredentials: + """Validate that credentials carry a bearer token.""" + if not isinstance(credentials, TokenCredentials): + raise TypeError("TokenCredentials are required for this server.") + return credentials + + +def _ensure_rexel(credentials: Credentials) -> RexelOAuthCodeCredentials: + """Validate that credentials are of Rexel OAuth code type.""" + if not isinstance(credentials, RexelOAuthCodeCredentials): + raise TypeError("RexelOAuthCodeCredentials are required for this server.") + return credentials diff --git a/pyoverkiz/auth/strategies.py b/pyoverkiz/auth/strategies.py new file mode 100644 index 00000000..761ba49c --- /dev/null +++ b/pyoverkiz/auth/strategies.py @@ -0,0 +1,471 @@ +"""Authentication strategies for Overkiz API.""" + +from __future__ import annotations + +import asyncio +import base64 +import binascii +import datetime +import json +import ssl +from collections.abc import Mapping +from typing import Any, cast + +import boto3 +from aiohttp import ClientSession, FormData +from botocore.client import BaseClient +from botocore.config import Config +from warrant_lite import WarrantLite + +from pyoverkiz.auth.base import AuthContext, AuthStrategy +from pyoverkiz.auth.credentials import ( + LocalTokenCredentials, + RexelOAuthCodeCredentials, + TokenCredentials, + UsernamePasswordCredentials, +) +from pyoverkiz.const import ( + COZYTOUCH_ATLANTIC_API, + COZYTOUCH_CLIENT_ID, + NEXITY_API, + NEXITY_COGNITO_CLIENT_ID, + NEXITY_COGNITO_REGION, + NEXITY_COGNITO_USER_POOL, + REXEL_OAUTH_CLIENT_ID, + REXEL_OAUTH_SCOPE, + REXEL_OAUTH_TOKEN_URL, + REXEL_REQUIRED_CONSENT, + SOMFY_API, + SOMFY_CLIENT_ID, + SOMFY_CLIENT_SECRET, +) +from pyoverkiz.enums import APIType +from pyoverkiz.exceptions import ( + BadCredentialsException, + CozyTouchBadCredentialsException, + CozyTouchServiceException, + InvalidTokenException, + NexityBadCredentialsException, + NexityServiceException, + SomfyBadCredentialsException, + SomfyServiceException, +) +from pyoverkiz.models import ServerConfig + + +class BaseAuthStrategy(AuthStrategy): + """Base class for authentication strategies.""" + + def __init__( + self, + session: ClientSession, + server: ServerConfig, + ssl_context: ssl.SSLContext | bool, + api_type: APIType, + ) -> None: + """Store shared auth context for Overkiz API interactions.""" + self.session = session + self.server = server + self._ssl = ssl_context + self.api_type = api_type + + async def login(self) -> None: + """Perform authentication; default is a no-op for subclasses to override.""" + return None + + async def refresh_if_needed(self) -> bool: + """Refresh authentication tokens if needed; default returns False.""" + return False + + def auth_headers(self, path: str | None = None) -> Mapping[str, str]: + """Return authentication headers for a request path.""" + return {} + + async def close(self) -> None: + """Close any resources held by the strategy; default is no-op.""" + return None + + +class SessionLoginStrategy(BaseAuthStrategy): + """Authentication strategy using session-based login.""" + + def __init__( + self, + credentials: UsernamePasswordCredentials, + session: ClientSession, + server: ServerConfig, + ssl_context: ssl.SSLContext | bool, + api_type: APIType, + ) -> None: + """Initialize SessionLoginStrategy with given parameters.""" + super().__init__(session, server, ssl_context, api_type) + self.credentials = credentials + + async def login(self) -> None: + """Perform login using username and password.""" + payload = { + "userId": self.credentials.username, + "userPassword": self.credentials.password, + } + await self._post_login(payload) + + async def _post_login(self, data: Mapping[str, Any]) -> None: + """Post login data to the server and handle response.""" + async with self.session.post( + f"{self.server.endpoint}login", + data=data, + ssl=self._ssl, + ) as response: + if response.status not in (200, 204): + raise BadCredentialsException( + f"Login failed for {self.server.name}: {response.status}" + ) + + # A 204 No Content response cannot have a body, so skip JSON parsing. + if response.status == 204: + return + + result = await response.json() + if not result.get("success"): + raise BadCredentialsException("Login failed: bad credentials") + + +class SomfyAuthStrategy(BaseAuthStrategy): + """Authentication strategy using Somfy OAuth2.""" + + def __init__( + self, + credentials: UsernamePasswordCredentials, + session: ClientSession, + server: ServerConfig, + ssl_context: ssl.SSLContext | bool, + api_type: APIType, + ) -> None: + """Initialize SomfyAuthStrategy with given parameters.""" + super().__init__(session, server, ssl_context, api_type) + self.credentials = credentials + self.context = AuthContext() + + async def login(self) -> None: + """Perform login using Somfy OAuth2.""" + await self._request_access_token( + grant_type="password", + extra_fields={ + "username": self.credentials.username, + "password": self.credentials.password, + }, + ) + + async def refresh_if_needed(self) -> bool: + """Refresh Somfy OAuth2 tokens if needed.""" + if not self.context.is_expired() or not self.context.refresh_token: + return False + + await self._request_access_token( + grant_type="refresh_token", + extra_fields={"refresh_token": cast(str, self.context.refresh_token)}, + ) + return True + + def auth_headers(self, path: str | None = None) -> Mapping[str, str]: + """Return authentication headers for a request path.""" + if self.context.access_token: + return {"Authorization": f"Bearer {self.context.access_token}"} + + return {} + + async def _request_access_token( + self, *, grant_type: str, extra_fields: Mapping[str, str] + ) -> None: + form = FormData( + { + "grant_type": grant_type, + "client_id": SOMFY_CLIENT_ID, + "client_secret": SOMFY_CLIENT_SECRET, + **extra_fields, + } + ) + + async with self.session.post( + f"{SOMFY_API}/oauth/oauth/v2/token/jwt", + data=form, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) as response: + token = await response.json() + + if token.get("message") == "error.invalid.grant": + raise SomfyBadCredentialsException(token["message"]) + + access_token = token.get("access_token") + if not access_token: + raise SomfyServiceException("No Somfy access token provided.") + + self.context.access_token = cast(str, access_token) + self.context.refresh_token = token.get("refresh_token") + expires_in = token.get("expires_in") + if expires_in: + self.context.expires_at = datetime.datetime.now( + datetime.UTC + ) + datetime.timedelta(seconds=cast(int, expires_in) - 5) + + +class CozytouchAuthStrategy(SessionLoginStrategy): + """Authentication strategy using Cozytouch session-based login.""" + + def __init__( + self, + credentials: UsernamePasswordCredentials, + session: ClientSession, + server: ServerConfig, + ssl_context: ssl.SSLContext | bool, + api_type: APIType, + ) -> None: + """Initialize CozytouchAuthStrategy with given parameters.""" + super().__init__(credentials, session, server, ssl_context, api_type) + + async def login(self) -> None: + """Perform login using Cozytouch username and password.""" + form = FormData( + { + "grant_type": "password", + "username": f"GA-PRIVATEPERSON/{self.credentials.username}", + "password": self.credentials.password, + } + ) + async with self.session.post( + f"{COZYTOUCH_ATLANTIC_API}/token", + data=form, + headers={ + "Authorization": f"Basic {COZYTOUCH_CLIENT_ID}", + "Content-Type": "application/x-www-form-urlencoded", + }, + ) as response: + token = await response.json() + + if token.get("error") == "invalid_grant": + raise CozyTouchBadCredentialsException(token["error_description"]) + + if "token_type" not in token: + raise CozyTouchServiceException("No CozyTouch token provided.") + + async with self.session.get( + f"{COZYTOUCH_ATLANTIC_API}/magellan/accounts/jwt", + headers={"Authorization": f"Bearer {token['access_token']}"}, + ) as response: + jwt = await response.text() + + if not jwt: + raise CozyTouchServiceException("No JWT token provided.") + + jwt = jwt.strip('"') + + await self._post_login({"jwt": jwt}) + + +class NexityAuthStrategy(SessionLoginStrategy): + """Authentication strategy using Nexity session-based login.""" + + def __init__( + self, + credentials: UsernamePasswordCredentials, + session: ClientSession, + server: ServerConfig, + ssl_context: ssl.SSLContext | bool, + api_type: APIType, + ) -> None: + """Initialize NexityAuthStrategy with given parameters.""" + super().__init__(credentials, session, server, ssl_context, api_type) + + async def login(self) -> None: + """Perform login using Nexity username and password.""" + loop = asyncio.get_event_loop() + + def _client() -> BaseClient: + return boto3.client( + "cognito-idp", config=Config(region_name=NEXITY_COGNITO_REGION) + ) + + client = await loop.run_in_executor(None, _client) + aws = WarrantLite( + username=self.credentials.username, + password=self.credentials.password, + pool_id=NEXITY_COGNITO_USER_POOL, + client_id=NEXITY_COGNITO_CLIENT_ID, + client=client, + ) + + try: + tokens = await loop.run_in_executor(None, aws.authenticate_user) + except Exception as error: + raise NexityBadCredentialsException() from error + + id_token = tokens["AuthenticationResult"]["IdToken"] + + async with self.session.get( + f"{NEXITY_API}/deploy/api/v1/domotic/token", + headers={"Authorization": id_token}, + ) as response: + token = await response.json() + + if "token" not in token: + raise NexityServiceException("No Nexity SSO token provided.") + + user_id = self.credentials.username.replace("@", "_-_") + await self._post_login({"ssoToken": token["token"], "userId": user_id}) + + +class LocalTokenAuthStrategy(BaseAuthStrategy): + """Authentication strategy using a local API token.""" + + def __init__( + self, + credentials: LocalTokenCredentials, + session: ClientSession, + server: ServerConfig, + ssl_context: ssl.SSLContext | bool, + api_type: APIType, + ) -> None: + """Initialize LocalTokenAuthStrategy with given parameters.""" + super().__init__(session, server, ssl_context, api_type) + self.credentials = credentials + + async def login(self) -> None: + """Validate that a token is provided for local API access.""" + if not self.credentials.token: + raise InvalidTokenException("Local API requires a token.") + + def auth_headers(self, path: str | None = None) -> Mapping[str, str]: + """Return authentication headers for a request path.""" + return {"Authorization": f"Bearer {self.credentials.token}"} + + +class RexelAuthStrategy(BaseAuthStrategy): + """Authentication strategy using Rexel OAuth2.""" + + def __init__( + self, + credentials: RexelOAuthCodeCredentials, + session: ClientSession, + server: ServerConfig, + ssl_context: ssl.SSLContext | bool, + api_type: APIType, + ) -> None: + """Initialize RexelAuthStrategy with given parameters.""" + super().__init__(session, server, ssl_context, api_type) + self.credentials = credentials + self.context = AuthContext() + + async def login(self) -> None: + """Perform login using Rexel OAuth2 authorization code.""" + await self._exchange_token( + { + "grant_type": "authorization_code", + "client_id": REXEL_OAUTH_CLIENT_ID, + "scope": REXEL_OAUTH_SCOPE, + "code": self.credentials.code, + "redirect_uri": self.credentials.redirect_uri, + } + ) + + async def refresh_if_needed(self) -> bool: + """Refresh Rexel OAuth2 tokens if needed.""" + if not self.context.is_expired() or not self.context.refresh_token: + return False + + await self._exchange_token( + { + "grant_type": "refresh_token", + "client_id": REXEL_OAUTH_CLIENT_ID, + "scope": REXEL_OAUTH_SCOPE, + "refresh_token": cast(str, self.context.refresh_token), + } + ) + return True + + def auth_headers(self, path: str | None = None) -> Mapping[str, str]: + """Return authentication headers for a request path.""" + if self.context.access_token: + return {"Authorization": f"Bearer {self.context.access_token}"} + return {} + + async def _exchange_token(self, payload: Mapping[str, str]) -> None: + """Exchange authorization code or refresh token for access token.""" + form = FormData(payload) + async with self.session.post( + REXEL_OAUTH_TOKEN_URL, + data=form, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) as response: + token = await response.json() + + # Handle OAuth error responses explicitly before accessing the access token. + error = token.get("error") + if error: + description = token.get("error_description") or token.get("message") + if description: + raise InvalidTokenException( + f"Error retrieving Rexel access token: {description}" + ) + raise InvalidTokenException( + f"Error retrieving Rexel access token: {error}" + ) + + access_token = token.get("access_token") + if not access_token: + raise InvalidTokenException("No Rexel access token provided.") + + self._ensure_consent(access_token) + self.context.access_token = cast(str, access_token) + self.context.refresh_token = token.get("refresh_token") + expires_in = token.get("expires_in") + if expires_in: + self.context.expires_at = datetime.datetime.now( + datetime.UTC + ) + datetime.timedelta(seconds=cast(int, expires_in) - 5) + + @staticmethod + def _ensure_consent(access_token: str) -> None: + """Ensure that the Rexel token has the required consent.""" + payload = _decode_jwt_payload(access_token) + consent = payload.get("consent") + if consent != REXEL_REQUIRED_CONSENT: + raise InvalidTokenException( + "Consent is missing or revoked for Rexel token." + ) + + +class BearerTokenAuthStrategy(BaseAuthStrategy): + """Authentication strategy using a static bearer token.""" + + def __init__( + self, + credentials: TokenCredentials, + session: ClientSession, + server: ServerConfig, + ssl_context: ssl.SSLContext | bool, + api_type: APIType, + ) -> None: + """Initialize BearerTokenAuthStrategy with given parameters.""" + super().__init__(session, server, ssl_context, api_type) + self.credentials = credentials + + def auth_headers(self, path: str | None = None) -> Mapping[str, str]: + """Return authentication headers for a request path.""" + if self.credentials.token: + return {"Authorization": f"Bearer {self.credentials.token}"} + return {} + + +def _decode_jwt_payload(token: str) -> dict[str, Any]: + """Decode the payload of a JWT token.""" + parts = token.split(".") + if len(parts) < 2: + raise InvalidTokenException("Malformed JWT received.") + + payload_segment = parts[1] + padding = "=" * (-len(payload_segment) % 4) + try: + decoded = base64.urlsafe_b64decode(payload_segment + padding) + return cast(dict[str, Any], json.loads(decoded)) + except (binascii.Error, json.JSONDecodeError) as error: + raise InvalidTokenException("Malformed JWT received.") from error diff --git a/pyoverkiz/client.py b/pyoverkiz/client.py index f934e263..5fb5ac9b 100644 --- a/pyoverkiz/client.py +++ b/pyoverkiz/client.py @@ -2,8 +2,6 @@ from __future__ import annotations -import asyncio -import datetime import os import ssl import urllib.parse @@ -13,38 +11,21 @@ from typing import Any, cast import backoff -import boto3 import humps from aiohttp import ( ClientConnectorError, ClientResponse, ClientSession, - FormData, ServerDisconnectedError, ) -from botocore.client import BaseClient -from botocore.config import Config -from warrant_lite import WarrantLite - -from pyoverkiz.const import ( - COZYTOUCH_ATLANTIC_API, - COZYTOUCH_CLIENT_ID, - LOCAL_API_PATH, - NEXITY_API, - NEXITY_COGNITO_CLIENT_ID, - NEXITY_COGNITO_REGION, - NEXITY_COGNITO_USER_POOL, - SOMFY_API, - SOMFY_CLIENT_ID, - SOMFY_CLIENT_SECRET, - SUPPORTED_SERVERS, -) -from pyoverkiz.enums import APIType, Server + +from pyoverkiz.action_queue import ActionQueue +from pyoverkiz.auth import AuthStrategy, Credentials, build_auth_strategy +from pyoverkiz.const import SUPPORTED_SERVERS +from pyoverkiz.enums import APIType, CommandMode, Server from pyoverkiz.exceptions import ( AccessDeniedToGatewayException, BadCredentialsException, - CozyTouchBadCredentialsException, - CozyTouchServiceException, ExecutionQueueFullException, InvalidCommandException, InvalidEventListenerIdException, @@ -52,8 +33,6 @@ MaintenanceException, MissingAPIKeyException, MissingAuthorizationTokenException, - NexityBadCredentialsException, - NexityServiceException, NoRegisteredEventListenerException, NoSuchResourceException, NotAuthenticatedException, @@ -61,8 +40,6 @@ OverkizException, ServiceUnavailableException, SessionAndBearerInSameRequestException, - SomfyBadCredentialsException, - SomfyServiceException, TooManyAttemptsBannedException, TooManyConcurrentRequestsException, TooManyExecutionsException, @@ -71,22 +48,22 @@ UnknownUserException, ) from pyoverkiz.models import ( - Command, + Action, + ActionGroup, Device, Event, Execution, Gateway, HistoryExecution, - LocalToken, Option, OptionParameter, - OverkizServer, Place, - Scenario, + ServerConfig, Setup, State, ) from pyoverkiz.obfuscate import obfuscate_sensitive_data +from pyoverkiz.serializers import prepare_payload from pyoverkiz.types import JSON @@ -137,10 +114,16 @@ def _create_local_ssl_context() -> ssl.SSLContext: This method is not async-friendly and should be called from a thread because it will load certificates from disk and do other blocking I/O. """ - return ssl.create_default_context( + context = ssl.create_default_context( cafile=os.path.dirname(os.path.realpath(__file__)) + "/overkiz-root-ca-2048.crt" ) + # Disable strict validation introduced in Python 3.13, which doesn't work with + # Overkiz self-signed gateway certificates. Applied once to the shared context. + context.verify_flags &= ~ssl.VERIFY_X509_STRICT + + return context + # The default SSLContext objects are created at import time # since they do blocking I/O to load certificates from disk, @@ -151,66 +134,75 @@ def _create_local_ssl_context() -> ssl.SSLContext: class OverkizClient: """Interface class for the Overkiz API.""" - username: str - password: str - server: OverkizServer + server_config: ServerConfig setup: Setup | None devices: list[Device] gateways: list[Gateway] event_listener_id: str | None session: ClientSession - api_type: APIType - - _refresh_token: str | None = None - _expires_in: datetime.datetime | None = None - _access_token: str | None = None _ssl: ssl.SSLContext | bool = True + _auth: AuthStrategy + _action_queue: ActionQueue | None = None def __init__( self, - username: str, - password: str, - server: OverkizServer, + *, + server: ServerConfig | Server | str, + credentials: Credentials, verify_ssl: bool = True, - token: str | None = None, session: ClientSession | None = None, + action_queue_enabled: bool = False, + action_queue_delay: float = 0.5, + action_queue_max_actions: int = 20, ) -> None: """Constructor. - :param username: the username - :param password: the password - :param server: OverkizServer + :param server: ServerConfig :param session: optional ClientSession + :param action_queue_enabled: enable action batching queue (default False) + :param action_queue_delay: seconds to wait before flushing queue (default 0.5) + :param action_queue_max_actions: maximum actions per batch before auto-flush (default 20) """ - self.username = username - self.password = password - self.server = server - self._access_token = token + self.server_config = self._normalize_server(server) self.setup: Setup | None = None self.devices: list[Device] = [] self.gateways: list[Gateway] = [] self.event_listener_id: str | None = None - self.session = session if session else ClientSession() + self.session = ( + session + if session + else ClientSession(headers={"User-Agent": "python-overkiz-api"}) + ) self._ssl = verify_ssl - if LOCAL_API_PATH in self.server.endpoint: - self.api_type = APIType.LOCAL - - if verify_ssl: - # To avoid security issues while authentication to local API, we add the following authority to - # our HTTPS client trust store: https://ca.overkiz.com/overkiz-root-ca-2048.crt - self._ssl = SSL_CONTEXT_LOCAL_API + if self.server_config.type == APIType.LOCAL and verify_ssl: + # Use the prebuilt SSL context with disabled strict validation for local API. + self._ssl = SSL_CONTEXT_LOCAL_API + + # Initialize action queue if enabled + if action_queue_enabled: + if action_queue_delay <= 0: + raise ValueError("action_queue_delay must be positive") + if action_queue_max_actions < 1: + raise ValueError("action_queue_max_actions must be at least 1") + + self._action_queue = ActionQueue( + executor=self._execute_action_group_direct, + delay=action_queue_delay, + max_actions=action_queue_max_actions, + ) - # Disable strict validation introduced in Python 3.13, which doesn't - # work with Overkiz self-signed gateway certificates - self._ssl.verify_flags &= ~ssl.VERIFY_X509_STRICT - else: - self.api_type = APIType.CLOUD + self._auth = build_auth_strategy( + server_config=self.server_config, + credentials=credentials, + session=self.session, + ssl_context=self._ssl, + ) async def __aenter__(self) -> OverkizClient: - """Enter the async context manager and return the client.""" + """Enter async context manager and return the client instance.""" return self async def __aexit__( @@ -222,11 +214,31 @@ async def __aexit__( """Exit the async context manager and close the client session.""" await self.close() + @staticmethod + def _normalize_server(server: ServerConfig | Server | str) -> ServerConfig: + """Resolve user-provided server identifiers into a `ServerConfig`.""" + if isinstance(server, ServerConfig): + return server + + server_key = server.value if isinstance(server, Server) else str(server) + + try: + return SUPPORTED_SERVERS[server_key] + except KeyError as error: + raise OverkizException( + f"Unknown server '{server_key}'. Provide a supported server key or ServerConfig instance." + ) from error + async def close(self) -> None: """Close the session.""" + # Flush any pending actions in queue + if self._action_queue: + await self._action_queue.shutdown() + if self.event_listener_id: await self.unregister_event_listener() + await self._auth.close() await self.session.close() async def login( @@ -237,207 +249,21 @@ async def login( Caller must provide one of [userId+userPassword, userId+ssoToken, accessToken, jwt]. """ - # Local authentication - if self.api_type == APIType.LOCAL: + await self._auth.login() + + if self.server_config.type == APIType.LOCAL: if register_event_listener: await self.register_event_listener() else: - # Call a simple endpoint to verify if our token is correct - # Since local API does not have a /login endpoint + # Validate local API token by calling a simple endpoint await self.get_gateways() return True - # Somfy TaHoma authentication using access_token - if self.server == SUPPORTED_SERVERS[Server.SOMFY_EUROPE]: - await self.somfy_tahoma_get_access_token() - - if register_event_listener: - await self.register_event_listener() - - return True - - # CozyTouch authentication using jwt - if self.server in [ - SUPPORTED_SERVERS[Server.ATLANTIC_COZYTOUCH], - SUPPORTED_SERVERS[Server.THERMOR_COZYTOUCH], - SUPPORTED_SERVERS[Server.SAUTER_COZYTOUCH], - ]: - jwt = await self.cozytouch_login() - payload = {"jwt": jwt} - - # Nexity authentication using ssoToken - elif self.server == SUPPORTED_SERVERS[Server.NEXITY]: - sso_token = await self.nexity_login() - user_id = self.username.replace("@", "_-_") # Replace @ for _-_ - payload = {"ssoToken": sso_token, "userId": user_id} - - # Regular authentication using userId+userPassword - else: - payload = {"userId": self.username, "userPassword": self.password} - - response = await self.__post("login", data=payload) - - if response.get("success"): - if register_event_listener: - await self.register_event_listener() - return True - - return False - - async def somfy_tahoma_get_access_token(self) -> str: - """Authenticate via Somfy identity and acquire access_token.""" - # Request access token - async with self.session.post( - SOMFY_API + "/oauth/oauth/v2/token/jwt", - data=FormData( - { - "grant_type": "password", - "username": self.username, - "password": self.password, - "client_id": SOMFY_CLIENT_ID, - "client_secret": SOMFY_CLIENT_SECRET, - } - ), - headers={ - "Content-Type": "application/x-www-form-urlencoded", - }, - ) as response: - token = await response.json() - - # { "message": "error.invalid.grant", "data": [], "uid": "xxx" } - if "message" in token and token["message"] == "error.invalid.grant": - raise SomfyBadCredentialsException(token["message"]) - - if "access_token" not in token: - raise SomfyServiceException("No Somfy access token provided.") - - self._access_token = cast(str, token["access_token"]) - self._refresh_token = token["refresh_token"] - self._expires_in = datetime.datetime.now() + datetime.timedelta( - seconds=token["expires_in"] - 5 - ) - - return self._access_token - - async def refresh_token(self) -> None: - """Update the access and the refresh token. The refresh token will be valid 14 days.""" - if self.server != SUPPORTED_SERVERS[Server.SOMFY_EUROPE]: - return - - if not self._refresh_token: - raise ValueError("No refresh token provided. Login method must be used.") - - # &grant_type=refresh_token&refresh_token=REFRESH_TOKEN - # Request access token - async with self.session.post( - SOMFY_API + "/oauth/oauth/v2/token/jwt", - data=FormData( - { - "grant_type": "refresh_token", - "refresh_token": self._refresh_token, - "client_id": SOMFY_CLIENT_ID, - "client_secret": SOMFY_CLIENT_SECRET, - } - ), - headers={ - "Content-Type": "application/x-www-form-urlencoded", - }, - ) as response: - token = await response.json() - # { "message": "error.invalid.grant", "data": [], "uid": "xxx" } - if "message" in token and token["message"] == "error.invalid.grant": - raise SomfyBadCredentialsException(token["message"]) - - if "access_token" not in token: - raise SomfyServiceException("No Somfy access token provided.") - - self._access_token = cast(str, token["access_token"]) - self._refresh_token = token["refresh_token"] - self._expires_in = datetime.datetime.now() + datetime.timedelta( - seconds=token["expires_in"] - 5 - ) - - async def cozytouch_login(self) -> str: - """Authenticate via CozyTouch identity and acquire JWT token.""" - # Request access token - async with self.session.post( - COZYTOUCH_ATLANTIC_API + "/token", - data=FormData( - { - "grant_type": "password", - "username": "GA-PRIVATEPERSON/" + self.username, - "password": self.password, - } - ), - headers={ - "Authorization": f"Basic {COZYTOUCH_CLIENT_ID}", - "Content-Type": "application/x-www-form-urlencoded", - }, - ) as response: - token = await response.json() - - # {'error': 'invalid_grant', - # 'error_description': 'Provided Authorization Grant is invalid.'} - if "error" in token and token["error"] == "invalid_grant": - raise CozyTouchBadCredentialsException(token["error_description"]) - - if "token_type" not in token: - raise CozyTouchServiceException("No CozyTouch token provided.") - - # Request JWT - async with self.session.get( - COZYTOUCH_ATLANTIC_API + "/magellan/accounts/jwt", - headers={"Authorization": f"Bearer {token['access_token']}"}, - ) as response: - jwt = await response.text() - - if not jwt: - raise CozyTouchServiceException("No JWT token provided.") - - jwt = jwt.strip('"') # Remove surrounding quotes - - return jwt - - async def nexity_login(self) -> str: - """Authenticate via Nexity identity and acquire SSO token.""" - loop = asyncio.get_event_loop() - - def _get_client() -> BaseClient: - return boto3.client( - "cognito-idp", config=Config(region_name=NEXITY_COGNITO_REGION) - ) - - # Request access token - client = await loop.run_in_executor(None, _get_client) + if register_event_listener: + await self.register_event_listener() - aws = WarrantLite( - username=self.username, - password=self.password, - pool_id=NEXITY_COGNITO_USER_POOL, - client_id=NEXITY_COGNITO_CLIENT_ID, - client=client, - ) - - try: - tokens = await loop.run_in_executor(None, aws.authenticate_user) - except Exception as error: - raise NexityBadCredentialsException() from error - - id_token = tokens["AuthenticationResult"]["IdToken"] - - async with self.session.get( - NEXITY_API + "/deploy/api/v1/domotic/token", - headers={ - "Authorization": id_token, - }, - ) as response: - token = await response.json() - - if "token" not in token: - raise NexityServiceException("No Nexity SSO token provided.") - - return cast(str, token["token"]) + return True @retry_on_auth_error async def get_setup(self, refresh: bool = False) -> Setup: @@ -630,100 +456,107 @@ async def get_api_version(self) -> str: @retry_on_too_many_executions @retry_on_auth_error - async def execute_command( + async def _execute_action_group_direct( self, - device_url: str, - command: Command | str, + actions: list[Action], + mode: CommandMode | None = None, label: str | None = "python-overkiz-api", ) -> str: - """Send a command.""" - if isinstance(command, str): - command = Command(command) + """Execute a non-persistent action group directly (internal method). - response: str = await self.execute_commands(device_url, [command], label) + The executed action group does not have to be persisted on the server before use. + Per-session rate-limit : 1 calls per 28min 48s period for all operations of the same category (exec) + """ + # Build a logical (snake_case) payload using model helpers and convert it + # to the exact JSON schema expected by the API (camelCase + small fixes). + payload = {"label": label, "actions": [a.to_payload() for a in actions]} + + # Prepare final payload with camelCase keys and special abbreviation handling + final_payload = prepare_payload(payload) + + if mode == CommandMode.GEOLOCATED: + url = "exec/apply/geolocated" + elif mode == CommandMode.INTERNAL: + url = "exec/apply/internal" + elif mode == CommandMode.HIGH_PRIORITY: + url = "exec/apply/highPriority" + else: + url = "exec/apply" - return response + response: dict = await self.__post(url, final_payload) - @retry_on_auth_error - async def cancel_command(self, exec_id: str) -> None: - """Cancel a running setup-level execution.""" - await self.__delete(f"/exec/current/setup/{exec_id}") + return cast(str, response["execId"]) - @retry_on_auth_error - async def execute_commands( + async def execute_action_group( self, - device_url: str, - commands: list[Command], + actions: list[Action], + mode: CommandMode | None = None, label: str | None = "python-overkiz-api", ) -> str: - """Send several commands in one call.""" - payload = { - "label": label, - "actions": [{"deviceURL": device_url, "commands": commands}], - } - response: dict = await self.__post("exec/apply", payload) - return cast(str, response["execId"]) + """Execute a non-persistent action group. - @retry_on_auth_error - async def get_scenarios(self) -> list[Scenario]: - """List the scenarios.""" - response = await self.__get("actionGroups") - return [Scenario(**scenario) for scenario in humps.decamelize(response)] + When action queue is enabled, actions will be batched with other actions + executed within the configured delay window. The method will wait for the + batch to execute and return the exec_id. - @retry_on_auth_error - async def get_places(self) -> Place: - """List the places.""" - response = await self.__get("setup/places") - places = Place(**humps.decamelize(response)) - return places + When action queue is disabled, executes immediately and returns exec_id. - @retry_on_auth_error - async def generate_local_token(self, gateway_id: str) -> str: - """Generates a new token. - - Access scope : Full enduser API access (enduser/*). - """ - response = await self.__get(f"config/{gateway_id}/local/tokens/generate") + The API is consistent regardless of queue configuration - always returns + exec_id string directly. - return cast(str, response["token"]) + :param actions: List of actions to execute + :param mode: Command mode (GEOLOCATED, INTERNAL, HIGH_PRIORITY, or None) + :param label: Label for the action group + :return: exec_id string from the executed action group - @retry_on_auth_error - async def activate_local_token( - self, gateway_id: str, token: str, label: str, scope: str = "devmode" - ) -> str: - """Create a token. + Example usage:: - Access scope : Full enduser API access (enduser/*). + # Works the same with or without queue + exec_id = await client.execute_action_group([action]) """ - response = await self.__post( - f"config/{gateway_id}/local/tokens", - {"label": label, "token": token, "scope": scope}, - ) - - return cast(str, response["requestId"]) + if self._action_queue: + queued = await self._action_queue.add(actions, mode, label) + return await queued + else: + return await self._execute_action_group_direct(actions, mode, label) - @retry_on_auth_error - async def get_local_tokens( - self, gateway_id: str, scope: str = "devmode" - ) -> list[LocalToken]: - """Get all gateway tokens with the given scope. + async def flush_action_queue(self) -> None: + """Force flush all pending actions in the queue immediately. - Access scope : Full enduser API access (enduser/*). + If action queue is disabled, this method does nothing. + If there are no pending actions, this method does nothing. """ - response = await self.__get(f"config/{gateway_id}/local/tokens/{scope}") - local_tokens = [LocalToken(**lt) for lt in humps.decamelize(response)] + if self._action_queue: + await self._action_queue.flush() - return local_tokens + def get_pending_actions_count(self) -> int: + """Get the number of actions currently waiting in the queue. + + Returns 0 if action queue is disabled. + """ + if self._action_queue: + return self._action_queue.get_pending_count() + return 0 @retry_on_auth_error - async def delete_local_token(self, gateway_id: str, uuid: str) -> bool: - """Delete a token. + async def cancel_command(self, exec_id: str) -> None: + """Cancel a running setup-level execution.""" + await self.__delete(f"/exec/current/setup/{exec_id}") - Access scope : Full enduser API access (enduser/*). - """ - await self.__delete(f"config/{gateway_id}/local/tokens/{uuid}") + @retry_on_auth_error + async def get_action_groups(self) -> list[ActionGroup]: + """List the action groups (scenarios).""" + response = await self.__get("actionGroups") + return [ + ActionGroup(**action_group) for action_group in humps.decamelize(response) + ] - return True + @retry_on_auth_error + async def get_places(self) -> Place: + """List the places.""" + response = await self.__get("setup/places") + places = Place(**humps.decamelize(response)) + return places @retry_on_auth_error async def execute_scenario(self, oid: str) -> str: @@ -782,14 +615,11 @@ async def get_setup_option_parameter( async def __get(self, path: str) -> Any: """Make a GET request to the OverKiz API.""" - headers = {} - await self._refresh_token_if_expired() - if self._access_token: - headers["Authorization"] = f"Bearer {self._access_token}" + headers = dict(self._auth.auth_headers(path)) async with self.session.get( - f"{self.server.endpoint}{path}", + f"{self.server_config.endpoint}{path}", headers=headers, ssl=self._ssl, ) as response: @@ -800,14 +630,11 @@ async def __post( self, path: str, payload: JSON | None = None, data: JSON | None = None ) -> Any: """Make a POST request to the OverKiz API.""" - headers = {} - - if path != "login" and self._access_token: - await self._refresh_token_if_expired() - headers["Authorization"] = f"Bearer {self._access_token}" + await self._refresh_token_if_expired() + headers = dict(self._auth.auth_headers(path)) async with self.session.post( - f"{self.server.endpoint}{path}", + f"{self.server_config.endpoint}{path}", data=data, json=payload, headers=headers, @@ -818,15 +645,11 @@ async def __post( async def __delete(self, path: str) -> None: """Make a DELETE request to the OverKiz API.""" - headers = {} - await self._refresh_token_if_expired() - - if self._access_token: - headers["Authorization"] = f"Bearer {self._access_token}" + headers = dict(self._auth.auth_headers(path)) async with self.session.delete( - f"{self.server.endpoint}{path}", + f"{self.server_config.endpoint}{path}", headers=headers, ssl=self._ssl, ) as response: @@ -941,12 +764,7 @@ async def check_response(response: ClientResponse) -> None: async def _refresh_token_if_expired(self) -> None: """Check if token is expired and request a new one.""" - if ( - self._expires_in - and self._refresh_token - and self._expires_in <= datetime.datetime.now() - ): - await self.refresh_token() - - if self.event_listener_id: - await self.register_event_listener() + refreshed = await self._auth.refresh_if_needed() + + if refreshed and self.event_listener_id: + await self.register_event_listener() diff --git a/pyoverkiz/const.py b/pyoverkiz/const.py index c664b5da..b4e515b1 100644 --- a/pyoverkiz/const.py +++ b/pyoverkiz/const.py @@ -3,7 +3,8 @@ from __future__ import annotations from pyoverkiz.enums import Server -from pyoverkiz.models import OverkizServer +from pyoverkiz.enums.server import APIType +from pyoverkiz.models import ServerConfig COZYTOUCH_ATLANTIC_API = "https://apis.groupe-atlantic.com" COZYTOUCH_CLIENT_ID = ( @@ -15,6 +16,18 @@ NEXITY_COGNITO_USER_POOL = "eu-west-1_wj277ucoI" NEXITY_COGNITO_REGION = "eu-west-1" +REXEL_BACKEND_API = ( + "https://app-ec-backend-enduser-prod.azurewebsites.net/api/enduser/overkiz/" +) +REXEL_OAUTH_CLIENT_ID = "2b635ede-c3fb-43bc-8d23-f6d17f80e96d" +REXEL_OAUTH_SCOPE = "https://adb2cservicesfrenduserprod.onmicrosoft.com/94f05108-65f7-477a-a84d-e67e1aed6f79/ExternalProvider" +REXEL_OAUTH_TENANT = ( + "https://consumerlogin.rexelservices.fr/670998c0-f737-4d75-a32f-ba9292755b70" +) +REXEL_OAUTH_POLICY = "B2C_1A_SIGNINONLYHOMEASSISTANT" +REXEL_OAUTH_TOKEN_URL = f"{REXEL_OAUTH_TENANT}/oauth2/v2.0/token?p={REXEL_OAUTH_POLICY}" +REXEL_REQUIRED_CONSENT = "homeassistant" + SOMFY_API = "https://accounts.somfy.com" SOMFY_CLIENT_ID = "0d8e920c-1478-11e7-a377-02dd59bd3041_1ewvaqmclfogo4kcsoo0c8k4kso884owg08sg8c40sk4go4ksg" SOMFY_CLIENT_SECRET = "12k73w1n540g8o4cokg0cw84cog840k84cwggscwg884004kgk" @@ -27,101 +40,117 @@ Server.SOMFY_AMERICA, ] -SUPPORTED_SERVERS: dict[str, OverkizServer] = { - Server.ATLANTIC_COZYTOUCH: OverkizServer( +SUPPORTED_SERVERS: dict[str, ServerConfig] = { + Server.ATLANTIC_COZYTOUCH: ServerConfig( + server=Server.ATLANTIC_COZYTOUCH, name="Atlantic Cozytouch", endpoint="https://ha110-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Atlantic", - configuration_url=None, + type=APIType.CLOUD, ), - Server.BRANDT: OverkizServer( + Server.BRANDT: ServerConfig( + server=Server.BRANDT, name="Brandt Smart Control", endpoint="https://ha3-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Brandt", - configuration_url=None, + type=APIType.CLOUD, ), - Server.FLEXOM: OverkizServer( + Server.FLEXOM: ServerConfig( + server=Server.FLEXOM, name="Flexom", endpoint="https://ha108-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Bouygues", - configuration_url=None, + type=APIType.CLOUD, ), - Server.HEXAOM_HEXACONNECT: OverkizServer( + Server.HEXAOM_HEXACONNECT: ServerConfig( + server=Server.HEXAOM_HEXACONNECT, name="Hexaom HexaConnect", endpoint="https://ha5-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Hexaom", - configuration_url=None, + type=APIType.CLOUD, ), - Server.HI_KUMO_ASIA: OverkizServer( + Server.HI_KUMO_ASIA: ServerConfig( + server=Server.HI_KUMO_ASIA, name="Hitachi Hi Kumo (Asia)", endpoint="https://ha203-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Hitachi", - configuration_url=None, + type=APIType.CLOUD, ), - Server.HI_KUMO_EUROPE: OverkizServer( + Server.HI_KUMO_EUROPE: ServerConfig( + server=Server.HI_KUMO_EUROPE, name="Hitachi Hi Kumo (Europe)", endpoint="https://ha117-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Hitachi", - configuration_url=None, + type=APIType.CLOUD, ), - Server.HI_KUMO_OCEANIA: OverkizServer( + Server.HI_KUMO_OCEANIA: ServerConfig( + server=Server.HI_KUMO_OCEANIA, name="Hitachi Hi Kumo (Oceania)", endpoint="https://ha203-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Hitachi", - configuration_url=None, + type=APIType.CLOUD, ), - Server.NEXITY: OverkizServer( + Server.NEXITY: ServerConfig( + server=Server.NEXITY, name="Nexity Eugénie", endpoint="https://ha106-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Nexity", - configuration_url=None, + type=APIType.CLOUD, ), - Server.REXEL: OverkizServer( + Server.REXEL: ServerConfig( + server=Server.REXEL, name="Rexel Energeasy Connect", - endpoint="https://ha112-1.overkiz.com/enduser-mobile-web/enduserAPI/", + endpoint=REXEL_BACKEND_API, manufacturer="Rexel", - configuration_url="https://utilisateur.energeasyconnect.com/user/#/zone/equipements", + type=APIType.CLOUD, ), - Server.SAUTER_COZYTOUCH: OverkizServer( # duplicate of Atlantic Cozytouch + Server.SAUTER_COZYTOUCH: ServerConfig( # duplicate of Atlantic Cozytouch + server=Server.SAUTER_COZYTOUCH, name="Sauter Cozytouch", endpoint="https://ha110-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Sauter", - configuration_url=None, + type=APIType.CLOUD, ), - Server.SIMU_LIVEIN2: OverkizServer( # alias of https://tahomalink.com + Server.SIMU_LIVEIN2: ServerConfig( # alias of https://tahomalink.com + server=Server.SIMU_LIVEIN2, name="SIMU (LiveIn2)", endpoint="https://ha101-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Somfy", - configuration_url=None, + type=APIType.CLOUD, ), - Server.SOMFY_EUROPE: OverkizServer( # alias of https://tahomalink.com + Server.SOMFY_EUROPE: ServerConfig( # alias of https://tahomalink.com + server=Server.SOMFY_EUROPE, name="Somfy (Europe)", endpoint="https://ha101-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Somfy", - configuration_url=None, + type=APIType.CLOUD, ), - Server.SOMFY_AMERICA: OverkizServer( + Server.SOMFY_AMERICA: ServerConfig( + server=Server.SOMFY_AMERICA, name="Somfy (North America)", endpoint="https://ha401-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Somfy", - configuration_url=None, + type=APIType.CLOUD, ), - Server.SOMFY_OCEANIA: OverkizServer( + Server.SOMFY_OCEANIA: ServerConfig( + server=Server.SOMFY_OCEANIA, name="Somfy (Oceania)", endpoint="https://ha201-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Somfy", - configuration_url=None, + type=APIType.CLOUD, ), - Server.THERMOR_COZYTOUCH: OverkizServer( # duplicate of Atlantic Cozytouch + Server.THERMOR_COZYTOUCH: ServerConfig( # duplicate of Atlantic Cozytouch + server=Server.THERMOR_COZYTOUCH, name="Thermor Cozytouch", endpoint="https://ha110-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Thermor", - configuration_url=None, + type=APIType.CLOUD, ), - Server.UBIWIZZ: OverkizServer( + Server.UBIWIZZ: ServerConfig( + server=Server.UBIWIZZ, name="Ubiwizz", endpoint="https://ha129-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Decelect", - configuration_url=None, + type=APIType.CLOUD, ), } diff --git a/pyoverkiz/enums/command.py b/pyoverkiz/enums/command.py index 645975af..09a65d56 100644 --- a/pyoverkiz/enums/command.py +++ b/pyoverkiz/enums/command.py @@ -1,14 +1,6 @@ """Command-related enums and parameters used by device commands.""" -import sys -from enum import unique - -# Since we support Python versions lower than 3.11, we use -# a backport for StrEnum when needed. -if sys.version_info >= (3, 11): - from enum import StrEnum -else: - from backports.strenum import StrEnum # type: ignore[import] +from enum import StrEnum, unique @unique diff --git a/pyoverkiz/enums/execution.py b/pyoverkiz/enums/execution.py index 3096e4b0..4ea2ea83 100644 --- a/pyoverkiz/enums/execution.py +++ b/pyoverkiz/enums/execution.py @@ -1,18 +1,10 @@ """Execution related enums (types, states and subtypes).""" import logging -import sys -from enum import unique +from enum import StrEnum, unique _LOGGER = logging.getLogger(__name__) -# Since we support Python versions lower than 3.11, we use -# a backport for StrEnum when needed. -if sys.version_info >= (3, 11): - from enum import StrEnum -else: - from backports.strenum import StrEnum # type: ignore[import] - @unique class ExecutionType(StrEnum): diff --git a/pyoverkiz/enums/gateway.py b/pyoverkiz/enums/gateway.py index feb43893..b64659a5 100644 --- a/pyoverkiz/enums/gateway.py +++ b/pyoverkiz/enums/gateway.py @@ -1,18 +1,10 @@ """Enums for gateway types and related helpers.""" import logging -import sys -from enum import IntEnum, unique +from enum import IntEnum, StrEnum, unique _LOGGER = logging.getLogger(__name__) -# Since we support Python versions lower than 3.11, we use -# a backport for StrEnum when needed. -if sys.version_info >= (3, 11): - from enum import StrEnum -else: - from backports.strenum import StrEnum # type: ignore[import] - @unique class GatewayType(IntEnum): diff --git a/pyoverkiz/enums/general.py b/pyoverkiz/enums/general.py index b3dd0d31..fa5bd5e8 100644 --- a/pyoverkiz/enums/general.py +++ b/pyoverkiz/enums/general.py @@ -1,18 +1,10 @@ """General-purpose enums like product types, data types and event names.""" import logging -import sys -from enum import IntEnum, unique +from enum import IntEnum, StrEnum, unique _LOGGER = logging.getLogger(__name__) -# Since we support Python versions lower than 3.11, we use -# a backport for StrEnum when needed. -if sys.version_info >= (3, 11): - from enum import StrEnum -else: - from backports.strenum import StrEnum # type: ignore[import] - @unique class ProductType(IntEnum): diff --git a/pyoverkiz/enums/measured_value_type.py b/pyoverkiz/enums/measured_value_type.py index aa011950..5c6911c7 100644 --- a/pyoverkiz/enums/measured_value_type.py +++ b/pyoverkiz/enums/measured_value_type.py @@ -1,14 +1,6 @@ """Measured value type enums used to interpret numeric sensor data.""" -import sys -from enum import unique - -# Since we support Python versions lower than 3.11, we use -# a backport for StrEnum when needed. -if sys.version_info >= (3, 11): - from enum import StrEnum -else: - from backports.strenum import StrEnum # type: ignore[import] +from enum import StrEnum, unique @unique diff --git a/pyoverkiz/enums/protocol.py b/pyoverkiz/enums/protocol.py index 70816828..d7372d94 100644 --- a/pyoverkiz/enums/protocol.py +++ b/pyoverkiz/enums/protocol.py @@ -1,18 +1,10 @@ """Protocol enums describe device URL schemes used by Overkiz.""" import logging -import sys -from enum import unique +from enum import StrEnum, unique _LOGGER = logging.getLogger(__name__) -# Since we support Python versions lower than 3.11, we use -# a backport for StrEnum when needed. -if sys.version_info >= (3, 11): - from enum import StrEnum -else: - from backports.strenum import StrEnum # type: ignore[import] - @unique class Protocol(StrEnum): diff --git a/pyoverkiz/enums/server.py b/pyoverkiz/enums/server.py index 3d495666..9970bddb 100644 --- a/pyoverkiz/enums/server.py +++ b/pyoverkiz/enums/server.py @@ -1,14 +1,6 @@ """Server and API type enums used to select target Overkiz endpoints.""" -import sys -from enum import unique - -# Since we support Python versions lower than 3.11, we use -# a backport for StrEnum when needed. -if sys.version_info >= (3, 11): - from enum import StrEnum -else: - from backports.strenum import StrEnum # type: ignore[import] +from enum import StrEnum, unique @unique diff --git a/pyoverkiz/enums/state.py b/pyoverkiz/enums/state.py index b74d9409..e9c6f549 100644 --- a/pyoverkiz/enums/state.py +++ b/pyoverkiz/enums/state.py @@ -1,14 +1,6 @@ """State and attribute enums describing Overkiz device states and attributes.""" -import sys -from enum import unique - -# Since we support Python versions lower than 3.11, we use -# a backport for StrEnum when needed. -if sys.version_info >= (3, 11): - from enum import StrEnum -else: - from backports.strenum import StrEnum # type: ignore[import] +from enum import StrEnum, unique @unique diff --git a/pyoverkiz/enums/ui.py b/pyoverkiz/enums/ui.py index 9c3b30ec..74656f91 100644 --- a/pyoverkiz/enums/ui.py +++ b/pyoverkiz/enums/ui.py @@ -1,18 +1,10 @@ """UI enums for classes and widgets used to interpret device UI metadata.""" import logging -import sys -from enum import unique +from enum import StrEnum, unique _LOGGER = logging.getLogger(__name__) -# Since we support Python versions lower than 3.11, we use -# a backport for StrEnum when needed. -if sys.version_info >= (3, 11): - from enum import StrEnum -else: - from backports.strenum import StrEnum # type: ignore[import] - @unique class UIClass(StrEnum): diff --git a/pyoverkiz/models.py b/pyoverkiz/models.py index ec090543..bf516cfd 100644 --- a/pyoverkiz/models.py +++ b/pyoverkiz/models.py @@ -22,7 +22,9 @@ UIWidget, UpdateBoxStatus, ) +from pyoverkiz.enums.command import OverkizCommand, OverkizCommandParam from pyoverkiz.enums.protocol import Protocol +from pyoverkiz.enums.server import APIType, Server from pyoverkiz.obfuscate import obfuscate_email, obfuscate_id, obfuscate_string from pyoverkiz.types import DATA_TYPE_TO_PYTHON, StateType @@ -465,19 +467,44 @@ def __len__(self) -> int: get = __getitem__ -class Command(dict): +@define(init=False, kw_only=True) +class Command: """Represents an OverKiz Command.""" - name: str - parameters: list[str | int | float] | None + type: int | None = None + name: str | OverkizCommand + parameters: list[str | int | float | OverkizCommandParam] | None def __init__( - self, name: str, parameters: list[str | int | float] | None = None, **_: Any + self, + name: OverkizCommand, + parameters: list[str | int | float | OverkizCommandParam] | None = None, + type: int | None = None, + **_: Any, ): """Initialize a command instance and mirror fields into dict base class.""" self.name = name self.parameters = parameters - dict.__init__(self, name=name, parameters=parameters) + self.type = type + + def to_payload(self) -> dict[str, object]: + """Return a JSON-serializable payload for this command. + + The payload uses snake_case keys; the client will convert to camelCase + and apply small key fixes (like `deviceURL`) before sending. + """ + payload: dict[str, object] = {"name": str(self.name)} + + if self.type is not None: + payload["type"] = self.type + + if self.parameters is not None: + payload["parameters"] = [ + p if isinstance(p, (str, int, float, bool)) else str(p) + for p in self.parameters # type: ignore[arg-type] + ] + + return payload @define(init=False, kw_only=True) @@ -574,7 +601,7 @@ class Execution: description: str owner: str = field(repr=obfuscate_email) state: str - action_group: list[dict[str, Any]] + action_group: ActionGroup def __init__( self, @@ -582,7 +609,7 @@ def __init__( description: str, owner: str, state: str, - action_group: list[dict[str, Any]], + action_group: dict[str, Any], **_: Any, ): """Initialize Execution object from API fields.""" @@ -590,7 +617,7 @@ def __init__( self.description = description self.owner = owner self.state = state - self.action_group = action_group + self.action_group = ActionGroup(**action_group) @define(init=False, kw_only=True) @@ -600,14 +627,26 @@ class Action: device_url: str commands: list[Command] - def __init__(self, device_url: str, commands: list[dict[str, Any]]): + def __init__(self, device_url: str, commands: list[dict[str, Any] | Command]): """Initialize Action from API data and convert nested commands.""" self.device_url = device_url - self.commands = [Command(**c) for c in commands] if commands else [] + self.commands = [ + c if isinstance(c, Command) else Command(**c) for c in commands + ] + + def to_payload(self) -> dict[str, object]: + """Return a JSON-serializable payload for this action (snake_case). + + The final camelCase conversion is handled by the client. + """ + return { + "device_url": self.device_url, + "commands": [c.to_payload() for c in self.commands], + } @define(init=False, kw_only=True) -class Scenario: +class ActionGroup: """An action group is composed of one or more actions. Each action is related to a single setup device (designated by its device URL) and @@ -615,10 +654,10 @@ class Scenario: """ id: str = field(repr=obfuscate_id) - creation_time: int + creation_time: int | None = None last_update_time: int | None = None label: str = field(repr=obfuscate_string) - metadata: str + metadata: str | None = None shortcut: bool | None = None notification_type_mask: int | None = None notification_condition: str | None = None @@ -629,10 +668,11 @@ class Scenario: def __init__( self, - creation_time: int, - metadata: str, actions: list[dict[str, Any]], - oid: str, + creation_time: int | None = None, + metadata: str | None = None, + oid: str | None = None, + id: str | None = None, last_update_time: int | None = None, label: str | None = None, shortcut: bool | None = None, @@ -642,8 +682,11 @@ def __init__( notification_title: str | None = None, **_: Any, ) -> None: - """Initialize Scenario (action group) from API data.""" - self.id = oid + """Initialize ActionGroup from API data and convert nested actions.""" + if oid is None and id is None: + raise ValueError("Either 'oid' or 'id' must be provided") + + self.id = cast(str, oid or id) self.creation_time = creation_time self.last_update_time = last_update_time self.label = ( @@ -656,7 +699,7 @@ def __init__( self.notification_text = notification_text self.notification_title = notification_title self.actions = [Action(**action) for action in actions] - self.oid = oid + self.oid = cast(str, oid or id) @define(init=False, kw_only=True) @@ -917,25 +960,36 @@ def __init__( @define(kw_only=True) -class OverkizServer: - """Class to describe an Overkiz server.""" +class ServerConfig: + """Connection target details for an Overkiz-compatible server.""" + server: Server | None name: str endpoint: str manufacturer: str - configuration_url: str | None + type: APIType + configuration_url: str | None = None - -@define(kw_only=True) -class LocalToken: - """Descriptor for a local gateway token.""" - - label: str - gateway_id: str = field(repr=obfuscate_id, default=None) - gateway_creation_time: int - uuid: str - scope: str - expiration_time: int | None + def __init__( + self, + *, + server: Server | str | None = None, + name: str, + endpoint: str, + manufacturer: str, + type: str | APIType, + configuration_url: str | None = None, + **_: Any, + ) -> None: + """Initialize ServerConfig and convert enum fields.""" + self.server = ( + server if isinstance(server, Server) or server is None else Server(server) + ) + self.name = name + self.endpoint = endpoint + self.manufacturer = manufacturer + self.type = type if isinstance(type, APIType) else APIType(type) + self.configuration_url = configuration_url @define(kw_only=True) diff --git a/pyoverkiz/serializers.py b/pyoverkiz/serializers.py new file mode 100644 index 00000000..ee4dc1aa --- /dev/null +++ b/pyoverkiz/serializers.py @@ -0,0 +1,39 @@ +"""Helpers for preparing API payloads. + +This module centralizes JSON key formatting and any small transport-specific +fixes (like mapping "deviceUrl" -> "deviceURL"). Models should produce +logical snake_case payloads and the client should call `prepare_payload` +before sending the payload to Overkiz. +""" + +from __future__ import annotations + +from typing import Any + +import humps + +# Small mapping for keys that need special casing beyond simple camelCase. +_ABBREV_MAP: dict[str, str] = {"deviceUrl": "deviceURL"} + + +def _fix_abbreviations(obj: Any) -> Any: + if isinstance(obj, dict): + out = {} + for k, v in obj.items(): + k = _ABBREV_MAP.get(k, k) + out[k] = _fix_abbreviations(v) + return out + if isinstance(obj, list): + return [_fix_abbreviations(i) for i in obj] + return obj + + +def prepare_payload(payload: Any) -> Any: + """Convert snake_case payload to API-ready camelCase and apply fixes. + + Example: + payload = {"device_url": "x", "commands": [{"name": "close"}]} + => {"deviceURL": "x", "commands": [{"name": "close"}]} + """ + camel = humps.camelize(payload) + return _fix_abbreviations(camel) diff --git a/pyoverkiz/utils.py b/pyoverkiz/utils.py index e395d0bf..6c28cdd3 100644 --- a/pyoverkiz/utils.py +++ b/pyoverkiz/utils.py @@ -5,21 +5,45 @@ import re from pyoverkiz.const import LOCAL_API_PATH -from pyoverkiz.models import OverkizServer +from pyoverkiz.enums.server import APIType, Server +from pyoverkiz.models import ServerConfig -def generate_local_server( +def create_local_server_config( + *, host: str, name: str = "Somfy Developer Mode", manufacturer: str = "Somfy", configuration_url: str | None = None, -) -> OverkizServer: - """Generate OverkizServer class for connection with a local API (Somfy Developer mode).""" - return OverkizServer( +) -> ServerConfig: + """Generate server configuration for a local API (Somfy Developer mode).""" + return create_server_config( name=name, endpoint=f"https://{host}{LOCAL_API_PATH}", manufacturer=manufacturer, + server=Server.SOMFY_DEVELOPER_MODE, configuration_url=configuration_url, + type=APIType.LOCAL, + ) + + +def create_server_config( + *, + name: str, + endpoint: str, + manufacturer: str, + server: Server | str | None = None, + type: APIType | str = APIType.CLOUD, + configuration_url: str | None = None, +) -> ServerConfig: + """Generate server configuration with the provided endpoint and metadata.""" + return ServerConfig( + server=server, # type: ignore[arg-type] + name=name, + endpoint=endpoint, + manufacturer=manufacturer, + configuration_url=configuration_url, + type=type, # type: ignore[arg-type] ) diff --git a/pyproject.toml b/pyproject.toml index 6fbe46f5..4296caa2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "pyoverkiz" -version = "1.19.3" -description = "Async Python client to interact with internal OverKiz API (e.g. used by Somfy TaHoma)." +version = "2.0.0" +description = "A fully asynchronous API client for interaction with smart devices connected to OverKiz, supporting multiple vendors such as Somfy TaHoma and Atlantic Cozytouch." readme = "README.md" authors = [ {name = "Mick Vleeshouwer", email = "mick@imick.nl"}, @@ -9,7 +9,7 @@ authors = [ {name = "Thibaut Etienne"}, ] license = {text = "MIT"} -requires-python = "<4.0,>=3.10" +requires-python = "<4.0,>=3.12" packages = [ { include = "pyoverkiz" } ] @@ -20,7 +20,6 @@ dependencies = [ "attrs>=21.2", "boto3<2.0.0,>=1.18.59", "warrant-lite<2.0.0,>=1.0.4", - "backports-strenum<2.0.0,>=1.2.4; python_version < \"3.11\"", ] [project.urls] diff --git a/test_queue_example.py b/test_queue_example.py new file mode 100644 index 00000000..8365e102 --- /dev/null +++ b/test_queue_example.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +# mypy: ignore-errors +# type: ignore + +"""Simple example demonstrating the action queue feature.""" + +from __future__ import annotations + +import asyncio + +from pyoverkiz.auth import UsernamePasswordCredentials +from pyoverkiz.client import OverkizClient +from pyoverkiz.enums import OverkizCommand, Server +from pyoverkiz.models import Action, Command + + +async def example_without_queue(): + """Example: Execute actions without queue (immediate execution).""" + print("\n=== Example 1: Without Queue (Immediate Execution) ===") + + client = OverkizClient( + server=Server.SOMFY_EUROPE, + credentials=UsernamePasswordCredentials("user@example.com", "password"), + action_queue_enabled=False, # Queue disabled + ) + + # Create some example actions + Action( + device_url="io://1234-5678-9012/12345678", + commands=[Command(name=OverkizCommand.CLOSE)], + ) + + # This will execute immediately + print("Executing action 1...") + # exec_id = await client.execute_action_group([action1]) + # print(f"Got exec_id immediately: {exec_id}") + + print("Without queue: Each call executes immediately as a separate API request") + await client.close() + + +async def example_with_queue(): + """Example: Execute actions with queue (batched execution).""" + print("\n=== Example 2: With Queue (Batched Execution) ===") + + client = OverkizClient( + server=Server.SOMFY_EUROPE, + credentials=UsernamePasswordCredentials("user@example.com", "password"), + action_queue_enabled=True, # Queue enabled! + action_queue_delay=0.5, # Wait 500ms before flushing + action_queue_max_actions=20, # Max 20 actions per batch + ) + + # Create some example actions + action1 = Action( + device_url="io://1234-5678-9012/12345678", + commands=[Command(name=OverkizCommand.CLOSE)], + ) + + action2 = Action( + device_url="io://1234-5678-9012/87654321", + commands=[Command(name=OverkizCommand.OPEN)], + ) + + action3 = Action( + device_url="io://1234-5678-9012/11111111", + commands=[Command(name=OverkizCommand.STOP)], + ) + + # These will be queued and batched together! + print("Queueing action 1...") + exec_id1 = await client.execute_action_group([action1]) + print(f"Got exec_id: {exec_id1}") + + print("Queueing action 2...") + exec_id2 = await client.execute_action_group([action2]) + + print("Queueing action 3...") + exec_id3 = await client.execute_action_group([action3]) + + print(f"Pending actions in queue: {client.get_pending_actions_count()}") + + # All three will have the same exec_id since they were batched together! + print(f"\nExec ID 1: {exec_id1}") + print(f"Exec ID 2: {exec_id2}") + print(f"Exec ID 3: {exec_id3}") + print(f"All same? {exec_id1 == exec_id2 == exec_id3}") + + print("\nWith queue: Multiple actions batched into single API request!") + await client.close() + + +async def example_manual_flush(): + """Example: Manually flush the queue.""" + print("\n=== Example 3: Manual Flush ===") + + client = OverkizClient( + server=Server.SOMFY_EUROPE, + credentials=UsernamePasswordCredentials("user@example.com", "password"), + action_queue_enabled=True, + action_queue_delay=10.0, # Long delay + ) + + action = Action( + device_url="io://1234-5678-9012/12345678", + commands=[Command(name=OverkizCommand.CLOSE)], + ) + + print("Queueing action with 10s delay...") + # Start execution in background (don't await yet) + exec_task = asyncio.create_task(client.execute_action_group([action])) + + # Give it a moment to queue + await asyncio.sleep(0.1) + print(f"Pending actions: {client.get_pending_actions_count()}") + + # Don't want to wait 10 seconds? Flush manually! + print("Manually flushing queue...") + await client.flush_action_queue() + + print(f"Pending actions after flush: {client.get_pending_actions_count()}") + + # Now get the result + exec_id = await exec_task + print(f"Got exec_id: {exec_id}") + + await client.close() + + +async def main(): + """Run all examples.""" + print("=" * 60) + print("Action Queue Feature Examples") + print("=" * 60) + + await example_without_queue() + await example_with_queue() + await example_manual_flush() + + print("\n" + "=" * 60) + print("Key Benefits:") + print("- Reduces API calls by batching actions") + print("- Helps avoid Overkiz rate limits") + print("- Perfect for scenes/automations with multiple devices") + print("- Fully backward compatible (disabled by default)") + print("=" * 60) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/fixtures/exec/current-tahoma-switch.json b/tests/fixtures/exec/current-tahoma-switch.json new file mode 100644 index 00000000..b15b44f9 --- /dev/null +++ b/tests/fixtures/exec/current-tahoma-switch.json @@ -0,0 +1,37 @@ +[ + { + "startTime": 1767003511145, + "owner": "somfy@imick.nl", + "actionGroup": { + "label": "Execution via Home Assistant", + "shortcut": false, + "notificationTypeMask": 0, + "notificationCondition": "NEVER", + "actions": [ + { + "deviceURL": "rts://2025-8464-6867/16756006", + "commands": [ + { + "type": 1, + "name": "close" + } + ] + }, + { + "deviceURL": "rts://2025-8464-6867/16719623", + "commands": [ + { + "type": 1, + "name": "identify" + } + ] + } + ] + }, + "description": "Execution : Execution via Home Assistant", + "id": "699dd967-0a19-0481-7a62-99b990a2feb8", + "state": "TRANSMITTED", + "executionType": "Immediate execution", + "executionSubType": "MANUAL_CONTROL" + } +] diff --git a/tests/test_action_queue.py b/tests/test_action_queue.py new file mode 100644 index 00000000..7e131397 --- /dev/null +++ b/tests/test_action_queue.py @@ -0,0 +1,273 @@ +"""Tests for ActionQueue.""" + +import asyncio +from unittest.mock import AsyncMock + +import pytest + +from pyoverkiz.action_queue import ActionQueue, QueuedExecution +from pyoverkiz.enums import CommandMode, OverkizCommand +from pyoverkiz.models import Action, Command + + +@pytest.fixture +def mock_executor(): + """Create a mock executor function.""" + + async def executor(actions, mode, label): + # Return immediately, no delay + return f"exec-{len(actions)}-{mode}-{label}" + + return AsyncMock(side_effect=executor) + + +@pytest.mark.asyncio +async def test_action_queue_single_action(mock_executor): + """Test queue with a single action.""" + queue = ActionQueue(executor=mock_executor, delay=0.1) + + action = Action( + device_url="io://1234-5678-9012/1", + commands=[Command(name=OverkizCommand.CLOSE)], + ) + + queued = await queue.add([action]) + assert isinstance(queued, QueuedExecution) + + # Wait for the batch to execute + exec_id = await queued + assert exec_id.startswith("exec-1-") + + # Verify executor was called + mock_executor.assert_called_once() + + +@pytest.mark.asyncio +async def test_action_queue_batching(mock_executor): + """Test that multiple actions are batched together.""" + queue = ActionQueue(executor=mock_executor, delay=0.2) + + actions = [ + Action( + device_url=f"io://1234-5678-9012/{i}", + commands=[Command(name=OverkizCommand.CLOSE)], + ) + for i in range(3) + ] + + # Add actions in quick succession + queued1 = await queue.add([actions[0]]) + queued2 = await queue.add([actions[1]]) + queued3 = await queue.add([actions[2]]) + + # All should return the same exec_id + exec_id1 = await queued1 + exec_id2 = await queued2 + exec_id3 = await queued3 + + assert exec_id1 == exec_id2 == exec_id3 + assert "exec-3-" in exec_id1 # 3 actions in batch + + # Executor should be called only once + mock_executor.assert_called_once() + + +@pytest.mark.asyncio +async def test_action_queue_max_actions_flush(mock_executor): + """Test that queue flushes when max actions is reached.""" + queue = ActionQueue(executor=mock_executor, delay=10.0, max_actions=3) + + actions = [ + Action( + device_url=f"io://1234-5678-9012/{i}", + commands=[Command(name=OverkizCommand.CLOSE)], + ) + for i in range(5) + ] + + # Add 3 actions - should trigger flush + queued1 = await queue.add([actions[0]]) + queued2 = await queue.add([actions[1]]) + queued3 = await queue.add([actions[2]]) + + # Wait a bit for flush to complete + await asyncio.sleep(0.05) + + # First 3 should be done + assert queued1.is_done() + assert queued2.is_done() + assert queued3.is_done() + + # Add 2 more - should start a new batch + queued4 = await queue.add([actions[3]]) + queued5 = await queue.add([actions[4]]) + + # Wait for second batch + await queued4 + await queued5 + + # Should have been called twice (2 batches) + assert mock_executor.call_count == 2 + + +@pytest.mark.asyncio +async def test_action_queue_mode_change_flush(mock_executor): + """Test that queue flushes when command mode changes.""" + queue = ActionQueue(executor=mock_executor, delay=0.5) + + action = Action( + device_url="io://1234-5678-9012/1", + commands=[Command(name=OverkizCommand.CLOSE)], + ) + + # Add action with normal mode + queued1 = await queue.add([action], mode=None) + + # Add action with high priority - should flush previous batch + queued2 = await queue.add([action], mode=CommandMode.HIGH_PRIORITY) + + # Wait for both batches + exec_id1 = await queued1 + exec_id2 = await queued2 + + # Should be different exec_ids (different batches) + assert exec_id1 != exec_id2 + + # Should have been called twice + assert mock_executor.call_count == 2 + + +@pytest.mark.asyncio +async def test_action_queue_label_change_flush(mock_executor): + """Test that queue flushes when label changes.""" + queue = ActionQueue(executor=mock_executor, delay=0.5) + + action = Action( + device_url="io://1234-5678-9012/1", + commands=[Command(name=OverkizCommand.CLOSE)], + ) + + # Add action with label1 + queued1 = await queue.add([action], label="label1") + + # Add action with label2 - should flush previous batch + queued2 = await queue.add([action], label="label2") + + # Wait for both batches + exec_id1 = await queued1 + exec_id2 = await queued2 + + # Should be different exec_ids (different batches) + assert exec_id1 != exec_id2 + + # Should have been called twice + assert mock_executor.call_count == 2 + + +@pytest.mark.asyncio +async def test_action_queue_manual_flush(mock_executor): + """Test manual flush of the queue.""" + queue = ActionQueue(executor=mock_executor, delay=10.0) # Long delay + + action = Action( + device_url="io://1234-5678-9012/1", + commands=[Command(name=OverkizCommand.CLOSE)], + ) + + queued = await queue.add([action]) + + # Manually flush + await queue.flush() + + # Should be done now + assert queued.is_done() + exec_id = await queued + assert exec_id.startswith("exec-1-") + + +@pytest.mark.asyncio +async def test_action_queue_shutdown(mock_executor): + """Test that shutdown flushes pending actions.""" + queue = ActionQueue(executor=mock_executor, delay=10.0) + + action = Action( + device_url="io://1234-5678-9012/1", + commands=[Command(name=OverkizCommand.CLOSE)], + ) + + queued = await queue.add([action]) + + # Shutdown should flush + await queue.shutdown() + + # Should be done + assert queued.is_done() + mock_executor.assert_called_once() + + +@pytest.mark.asyncio +async def test_action_queue_error_propagation(mock_executor): + """Test that exceptions are propagated to all waiters.""" + # Make executor raise an exception + mock_executor.side_effect = ValueError("API Error") + + queue = ActionQueue(executor=mock_executor, delay=0.1) + + action = Action( + device_url="io://1234-5678-9012/1", + commands=[Command(name=OverkizCommand.CLOSE)], + ) + + queued1 = await queue.add([action]) + queued2 = await queue.add([action]) + + # Both should raise the same exception + with pytest.raises(ValueError, match="API Error"): + await queued1 + + with pytest.raises(ValueError, match="API Error"): + await queued2 + + +@pytest.mark.asyncio +async def test_action_queue_get_pending_count(): + """Test getting pending action count.""" + mock_executor = AsyncMock(return_value="exec-123") + queue = ActionQueue(executor=mock_executor, delay=0.5) + + assert queue.get_pending_count() == 0 + + action = Action( + device_url="io://1234-5678-9012/1", + commands=[Command(name=OverkizCommand.CLOSE)], + ) + + await queue.add([action]) + assert queue.get_pending_count() == 1 + + await queue.add([action]) + assert queue.get_pending_count() == 2 + + # Wait for flush + await asyncio.sleep(0.6) + assert queue.get_pending_count() == 0 + + +@pytest.mark.asyncio +async def test_queued_execution_awaitable(): + """Test that QueuedExecution is properly awaitable.""" + queued = QueuedExecution() + + # Set result in background + async def set_result(): + await asyncio.sleep(0.05) + queued.set_result("exec-123") + + task = asyncio.create_task(set_result()) + + # Await the result + result = await queued + assert result == "exec-123" + + # Ensure background task has completed + await task diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 00000000..8106d29d --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,534 @@ +"""Tests for authentication module.""" + +from __future__ import annotations + +import base64 +import datetime +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest +from aiohttp import ClientSession + +from pyoverkiz.auth.base import AuthContext +from pyoverkiz.auth.credentials import ( + LocalTokenCredentials, + RexelOAuthCodeCredentials, + TokenCredentials, + UsernamePasswordCredentials, +) +from pyoverkiz.auth.factory import ( + _ensure_rexel, + _ensure_token, + _ensure_username_password, + build_auth_strategy, +) +from pyoverkiz.auth.strategies import ( + BearerTokenAuthStrategy, + CozytouchAuthStrategy, + LocalTokenAuthStrategy, + NexityAuthStrategy, + RexelAuthStrategy, + SessionLoginStrategy, + SomfyAuthStrategy, + _decode_jwt_payload, +) +from pyoverkiz.enums import APIType, Server +from pyoverkiz.exceptions import InvalidTokenException +from pyoverkiz.models import ServerConfig + + +class TestAuthContext: + """Test AuthContext functionality.""" + + def test_not_expired_no_expiration(self): + """Test that context without expiration is not expired.""" + context = AuthContext(access_token="test_token") + assert not context.is_expired() + + def test_not_expired_future_expiration(self): + """Test that context with future expiration is not expired.""" + future = datetime.datetime.now(datetime.UTC) + datetime.timedelta(hours=1) + context = AuthContext(access_token="test_token", expires_at=future) + assert not context.is_expired() + + def test_expired_past_expiration(self): + """Test that context with past expiration is expired.""" + past = datetime.datetime.now(datetime.UTC) - datetime.timedelta(hours=1) + context = AuthContext(access_token="test_token", expires_at=past) + assert context.is_expired() + + def test_expired_with_skew(self): + """Test that context respects skew time.""" + # Expires in 3 seconds, but default skew is 5 + soon = datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=3) + context = AuthContext(access_token="test_token", expires_at=soon) + assert context.is_expired() + + def test_not_expired_with_custom_skew(self): + """Test that custom skew time can be provided.""" + soon = datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=3) + context = AuthContext(access_token="test_token", expires_at=soon) + assert not context.is_expired(skew_seconds=1) + + +class TestCredentials: + """Test credential dataclasses.""" + + def test_username_password_credentials(self): + """Test UsernamePasswordCredentials creation.""" + creds = UsernamePasswordCredentials("user@example.com", "password123") + assert creds.username == "user@example.com" + assert creds.password == "password123" + + def test_token_credentials(self): + """Test TokenCredentials creation.""" + creds = TokenCredentials("my_token_123") + assert creds.token == "my_token_123" + + def test_local_token_credentials(self): + """Test LocalTokenCredentials creation.""" + creds = LocalTokenCredentials("local_token_456") + assert creds.token == "local_token_456" + assert isinstance(creds, TokenCredentials) + + def test_rexel_oauth_credentials(self): + """Test RexelOAuthCodeCredentials creation.""" + creds = RexelOAuthCodeCredentials("auth_code_xyz", "http://redirect.uri") + assert creds.code == "auth_code_xyz" + assert creds.redirect_uri == "http://redirect.uri" + + +class TestAuthFactory: + """Test authentication factory functions.""" + + def test_ensure_username_password_valid(self): + """Test that valid username/password credentials pass validation.""" + creds = UsernamePasswordCredentials("user", "pass") + result = _ensure_username_password(creds) + assert result is creds + + def test_ensure_username_password_invalid(self): + """Test that invalid credentials raise TypeError.""" + creds = TokenCredentials("token") + with pytest.raises(TypeError, match="UsernamePasswordCredentials are required"): + _ensure_username_password(creds) + + def test_ensure_token_valid(self): + """Test that valid token credentials pass validation.""" + creds = TokenCredentials("token") + result = _ensure_token(creds) + assert result is creds + + def test_ensure_token_local_valid(self): + """Test that LocalTokenCredentials also pass token validation.""" + creds = LocalTokenCredentials("local_token") + result = _ensure_token(creds) + assert result is creds + + def test_ensure_token_invalid(self): + """Test that invalid credentials raise TypeError.""" + creds = UsernamePasswordCredentials("user", "pass") + with pytest.raises(TypeError, match="TokenCredentials are required"): + _ensure_token(creds) + + def test_ensure_rexel_valid(self): + """Test that valid Rexel credentials pass validation.""" + creds = RexelOAuthCodeCredentials("code", "uri") + result = _ensure_rexel(creds) + assert result is creds + + def test_ensure_rexel_invalid(self): + """Test that invalid credentials raise TypeError.""" + creds = UsernamePasswordCredentials("user", "pass") + with pytest.raises(TypeError, match="RexelOAuthCodeCredentials are required"): + _ensure_rexel(creds) + + @pytest.mark.asyncio + async def test_build_auth_strategy_somfy(self): + """Test building Somfy auth strategy.""" + server_config = ServerConfig( + server=Server.SOMFY_EUROPE, + name="Somfy", + endpoint="https://api.somfy.com", + manufacturer="Somfy", + type=APIType.CLOUD, + ) + credentials = UsernamePasswordCredentials("user", "pass") + session = AsyncMock(spec=ClientSession) + + strategy = build_auth_strategy( + server_config=server_config, + credentials=credentials, + session=session, + ssl_context=True, + ) + + assert isinstance(strategy, SomfyAuthStrategy) + + @pytest.mark.asyncio + async def test_build_auth_strategy_cozytouch(self): + """Test building Cozytouch auth strategy.""" + server_config = ServerConfig( + server=Server.ATLANTIC_COZYTOUCH, + name="Cozytouch", + endpoint="https://api.cozytouch.com", + manufacturer="Atlantic", + type=APIType.CLOUD, + ) + credentials = UsernamePasswordCredentials("user", "pass") + session = AsyncMock(spec=ClientSession) + + strategy = build_auth_strategy( + server_config=server_config, + credentials=credentials, + session=session, + ssl_context=True, + ) + + assert isinstance(strategy, CozytouchAuthStrategy) + + @pytest.mark.asyncio + async def test_build_auth_strategy_nexity(self): + """Test building Nexity auth strategy.""" + server_config = ServerConfig( + server=Server.NEXITY, + name="Nexity", + endpoint="https://api.nexity.com", + manufacturer="Nexity", + type=APIType.CLOUD, + ) + credentials = UsernamePasswordCredentials("user", "pass") + session = AsyncMock(spec=ClientSession) + + strategy = build_auth_strategy( + server_config=server_config, + credentials=credentials, + session=session, + ssl_context=True, + ) + + assert isinstance(strategy, NexityAuthStrategy) + + @pytest.mark.asyncio + async def test_build_auth_strategy_rexel(self): + """Test building Rexel auth strategy.""" + server_config = ServerConfig( + server=Server.REXEL, + name="Rexel", + endpoint="https://api.rexel.com", + manufacturer="Rexel", + type=APIType.CLOUD, + ) + credentials = RexelOAuthCodeCredentials("code", "http://redirect.uri") + session = AsyncMock(spec=ClientSession) + + strategy = build_auth_strategy( + server_config=server_config, + credentials=credentials, + session=session, + ssl_context=True, + ) + + assert isinstance(strategy, RexelAuthStrategy) + + @pytest.mark.asyncio + async def test_build_auth_strategy_local_token(self): + """Test building local token auth strategy.""" + server_config = ServerConfig( + server=None, + name="Local", + endpoint="https://gateway.local", + manufacturer="Overkiz", + type=APIType.LOCAL, + ) + credentials = LocalTokenCredentials("local_token") + session = AsyncMock(spec=ClientSession) + + strategy = build_auth_strategy( + server_config=server_config, + credentials=credentials, + session=session, + ssl_context=True, + ) + + assert isinstance(strategy, LocalTokenAuthStrategy) + + @pytest.mark.asyncio + async def test_build_auth_strategy_local_bearer(self): + """Test building local bearer token auth strategy.""" + server_config = ServerConfig( + server=None, + name="Local", + endpoint="https://gateway.local", + manufacturer="Overkiz", + type=APIType.LOCAL, + ) + credentials = TokenCredentials("bearer_token") + session = AsyncMock(spec=ClientSession) + + strategy = build_auth_strategy( + server_config=server_config, + credentials=credentials, + session=session, + ssl_context=True, + ) + + assert isinstance(strategy, BearerTokenAuthStrategy) + + @pytest.mark.asyncio + async def test_build_auth_strategy_cloud_bearer(self): + """Test building cloud bearer token auth strategy.""" + server_config = ServerConfig( + server=Server.SOMFY_OCEANIA, + name="Somfy Oceania", + endpoint="https://api.somfy.com.au", + manufacturer="Somfy", + type=APIType.CLOUD, + ) + credentials = TokenCredentials("bearer_token") + session = AsyncMock(spec=ClientSession) + + strategy = build_auth_strategy( + server_config=server_config, + credentials=credentials, + session=session, + ssl_context=True, + ) + + assert isinstance(strategy, BearerTokenAuthStrategy) + + @pytest.mark.asyncio + async def test_build_auth_strategy_session_login(self): + """Test building generic session login auth strategy.""" + server_config = ServerConfig( + server=Server.SOMFY_OCEANIA, + name="Somfy Oceania", + endpoint="https://api.somfy.com.au", + manufacturer="Somfy", + type=APIType.CLOUD, + ) + credentials = UsernamePasswordCredentials("user", "pass") + session = AsyncMock(spec=ClientSession) + + strategy = build_auth_strategy( + server_config=server_config, + credentials=credentials, + session=session, + ssl_context=True, + ) + + assert isinstance(strategy, SessionLoginStrategy) + + @pytest.mark.asyncio + async def test_build_auth_strategy_wrong_credentials_type(self): + """Test that wrong credentials type raises TypeError.""" + server_config = ServerConfig( + server=Server.SOMFY_EUROPE, + name="Somfy", + endpoint="https://api.somfy.com", + manufacturer="Somfy", + type=APIType.CLOUD, + ) + credentials = TokenCredentials("token") # Wrong type for Somfy + session = AsyncMock(spec=ClientSession) + + with pytest.raises(TypeError, match="UsernamePasswordCredentials are required"): + build_auth_strategy( + server_config=server_config, + credentials=credentials, + session=session, + ssl_context=True, + ) + + +class TestSessionLoginStrategy: + """Test SessionLoginStrategy.""" + + @pytest.mark.asyncio + async def test_login_success(self): + """Test successful login with 200 response.""" + server_config = ServerConfig( + server=Server.SOMFY_OCEANIA, + name="Test", + endpoint="https://api.test.com/", + manufacturer="Test", + type=APIType.CLOUD, + ) + credentials = UsernamePasswordCredentials("user", "pass") + session = AsyncMock(spec=ClientSession) + + mock_response = MagicMock() + mock_response.status = 200 + mock_response.json = AsyncMock(return_value={"success": True}) + mock_response.__aenter__ = AsyncMock(return_value=mock_response) + mock_response.__aexit__ = AsyncMock(return_value=None) + session.post = MagicMock(return_value=mock_response) + + strategy = SessionLoginStrategy( + credentials, session, server_config, True, APIType.CLOUD + ) + await strategy.login() + + session.post.assert_called_once() + + @pytest.mark.asyncio + async def test_login_204_no_content(self): + """Test login with 204 No Content response.""" + server_config = ServerConfig( + server=Server.SOMFY_OCEANIA, + name="Test", + endpoint="https://api.test.com/", + manufacturer="Test", + type=APIType.CLOUD, + ) + credentials = UsernamePasswordCredentials("user", "pass") + session = AsyncMock(spec=ClientSession) + + mock_response = MagicMock() + mock_response.status = 204 + mock_response.json = AsyncMock() + mock_response.__aenter__ = AsyncMock(return_value=mock_response) + mock_response.__aexit__ = AsyncMock(return_value=None) + session.post = MagicMock(return_value=mock_response) + + strategy = SessionLoginStrategy( + credentials, session, server_config, True, APIType.CLOUD + ) + await strategy.login() + + # Should not call json() for 204 response + assert not mock_response.json.called + + @pytest.mark.asyncio + async def test_refresh_if_needed_no_refresh(self): + """Test that refresh_if_needed returns False when no refresh needed.""" + server_config = ServerConfig( + server=Server.SOMFY_OCEANIA, + name="Test", + endpoint="https://api.test.com/", + manufacturer="Test", + type=APIType.CLOUD, + ) + credentials = UsernamePasswordCredentials("user", "pass") + session = AsyncMock(spec=ClientSession) + + strategy = SessionLoginStrategy( + credentials, session, server_config, True, APIType.CLOUD + ) + result = await strategy.refresh_if_needed() + + assert not result + + def test_auth_headers_no_token(self): + """Test that auth headers return empty dict when no token.""" + server_config = ServerConfig( + server=Server.SOMFY_OCEANIA, + name="Test", + endpoint="https://api.test.com/", + manufacturer="Test", + type=APIType.CLOUD, + ) + credentials = UsernamePasswordCredentials("user", "pass") + session = AsyncMock(spec=ClientSession) + + strategy = SessionLoginStrategy( + credentials, session, server_config, True, APIType.CLOUD + ) + headers = strategy.auth_headers() + + assert headers == {} + + +class TestBearerTokenAuthStrategy: + """Test BearerTokenAuthStrategy.""" + + @pytest.mark.asyncio + async def test_login_no_op(self): + """Test that login is a no-op for bearer tokens.""" + server_config = ServerConfig( + server=None, + name="Test", + endpoint="https://api.test.com/", + manufacturer="Test", + type=APIType.CLOUD, + ) + credentials = TokenCredentials("my_bearer_token") + session = AsyncMock(spec=ClientSession) + + strategy = BearerTokenAuthStrategy( + credentials, session, server_config, True, APIType.CLOUD + ) + result = await strategy.login() + + # Login should be a no-op + assert result is None + + def test_auth_headers_with_token(self): + """Test that auth headers include Bearer token.""" + server_config = ServerConfig( + server=None, + name="Test", + endpoint="https://api.test.com/", + manufacturer="Test", + type=APIType.CLOUD, + ) + credentials = TokenCredentials("my_bearer_token") + session = AsyncMock(spec=ClientSession) + + strategy = BearerTokenAuthStrategy( + credentials, session, server_config, True, APIType.CLOUD + ) + headers = strategy.auth_headers() + + assert headers == {"Authorization": "Bearer my_bearer_token"} + + +class TestRexelAuthStrategy: + """Tests for Rexel auth specifics.""" + + @pytest.mark.asyncio + async def test_exchange_token_error_response(self): + """Ensure OAuth error payloads raise InvalidTokenException before parsing access token.""" + server_config = ServerConfig( + server=Server.REXEL, + name="Rexel", + endpoint="https://api.rexel.com", + manufacturer="Rexel", + type=APIType.CLOUD, + ) + credentials = RexelOAuthCodeCredentials("code", "https://redirect") + session = AsyncMock(spec=ClientSession) + + mock_response = MagicMock() + mock_response.status = 400 + mock_response.json = AsyncMock( + return_value={"error": "invalid_grant", "error_description": "bad grant"} + ) + mock_response.__aenter__ = AsyncMock(return_value=mock_response) + mock_response.__aexit__ = AsyncMock(return_value=None) + session.post = MagicMock(return_value=mock_response) + + strategy = RexelAuthStrategy( + credentials, session, server_config, True, APIType.CLOUD + ) + + with pytest.raises(InvalidTokenException, match="bad grant"): + await strategy._exchange_token({"grant_type": "authorization_code"}) + + def test_ensure_consent_missing(self): + """Raising when JWT consent claim is missing or incorrect.""" + payload_segment = ( + base64.urlsafe_b64encode(json.dumps({"consent": "other"}).encode()) + .decode() + .rstrip("=") + ) + token = f"header.{payload_segment}.sig" + + with pytest.raises(InvalidTokenException, match="Consent is missing"): + RexelAuthStrategy._ensure_consent(token) + + def test_decode_jwt_payload_invalid_format(self): + """Malformed tokens raise InvalidTokenException during decoding.""" + with pytest.raises(InvalidTokenException): + _decode_jwt_payload("invalid.token") diff --git a/tests/test_client.py b/tests/test_client.py index 7cd5f396..af68ffbf 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -11,11 +11,14 @@ from pytest_asyncio import fixture from pyoverkiz import exceptions +from pyoverkiz.auth.credentials import ( + LocalTokenCredentials, + UsernamePasswordCredentials, +) from pyoverkiz.client import OverkizClient -from pyoverkiz.const import SUPPORTED_SERVERS -from pyoverkiz.enums import APIType, DataType +from pyoverkiz.enums import APIType, DataType, Server from pyoverkiz.models import Option -from pyoverkiz.utils import generate_local_server +from pyoverkiz.utils import create_local_server_config CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -26,26 +29,28 @@ class TestOverkizClient: @fixture async def client(self): """Fixture providing an OverkizClient configured for the cloud server.""" - return OverkizClient("username", "password", SUPPORTED_SERVERS["somfy_europe"]) + return OverkizClient( + server=Server.SOMFY_EUROPE, + credentials=UsernamePasswordCredentials("username", "password"), + ) @fixture async def local_client(self): """Fixture providing an OverkizClient configured for a local (developer) server.""" return OverkizClient( - "username", - "password", - generate_local_server("gateway-1234-5678-1243.local:8443"), + server=create_local_server_config(host="gateway-1234-5678-1243.local:8443"), + credentials=LocalTokenCredentials(token="token"), ) @pytest.mark.asyncio async def test_get_api_type_cloud(self, client: OverkizClient): """Verify that a cloud-configured client reports APIType.CLOUD.""" - assert client.api_type == APIType.CLOUD + assert client.server_config.type == APIType.CLOUD @pytest.mark.asyncio async def test_get_api_type_local(self, local_client: OverkizClient): """Verify that a local-configured client reports APIType.LOCAL.""" - assert local_client.api_type == APIType.LOCAL + assert local_client.server_config.type == APIType.LOCAL @pytest.mark.asyncio async def test_get_devices_basic(self, client: OverkizClient): @@ -379,6 +384,41 @@ async def test_get_setup_options( for option in options: assert isinstance(option, Option) + @pytest.mark.asyncio + async def test_execute_action_group_omits_none_fields(self, client: OverkizClient): + """Ensure `type` and `parameters` that are None are omitted from the request payload.""" + from pyoverkiz.enums.command import OverkizCommand + from pyoverkiz.models import Action, Command + + action = Action( + "rts://2025-8464-6867/16756006", + [Command(name=OverkizCommand.CLOSE, parameters=None, type=None)], + ) + + resp = MockResponse('{"execId": "exec-123"}') + + with patch.object(aiohttp.ClientSession, "post") as mock_post: + mock_post.return_value = resp + + exec_id = await client.execute_action_group([action]) + + assert exec_id == "exec-123" + + assert mock_post.called + _, kwargs = mock_post.call_args + sent_json = kwargs.get("json") + assert sent_json is not None + + # The client should have converted payload to camelCase and applied + # abbreviation fixes (deviceURL) before sending. + action_sent = sent_json["actions"][0] + assert action_sent.get("deviceURL") == action.device_url + + cmd = action_sent["commands"][0] + assert "type" not in cmd + assert "parameters" not in cmd + assert cmd["name"] == "close" + @pytest.mark.parametrize( "fixture_name, option_name, instance", [ @@ -421,7 +461,7 @@ async def test_get_setup_option( ], ) @pytest.mark.asyncio - async def test_get_scenarios( + async def test_get_action_groups( self, client: OverkizClient, fixture_name: str, @@ -435,16 +475,16 @@ async def test_get_scenarios( resp = MockResponse(action_group_mock.read()) with patch.object(aiohttp.ClientSession, "get", return_value=resp): - scenarios = await client.get_scenarios() + action_groups = await client.get_action_groups() - assert len(scenarios) == scenario_count + assert len(action_groups) == scenario_count - for scenario in scenarios: - assert scenario.oid - assert scenario.label is not None - assert scenario.actions + for action_group in action_groups: + assert action_group.oid + assert action_group.label is not None + assert action_group.actions - for action in scenario.actions: + for action in action_group.actions: assert action.device_url assert action.commands diff --git a/tests/test_client_queue_integration.py b/tests/test_client_queue_integration.py new file mode 100644 index 00000000..100b00d3 --- /dev/null +++ b/tests/test_client_queue_integration.py @@ -0,0 +1,224 @@ +"""Integration tests for OverkizClient with ActionQueue.""" + +import asyncio +from unittest.mock import AsyncMock, patch + +import pytest + +from pyoverkiz.auth import UsernamePasswordCredentials +from pyoverkiz.client import OverkizClient +from pyoverkiz.enums import OverkizCommand, Server +from pyoverkiz.models import Action, Command + + +@pytest.mark.asyncio +async def test_client_without_queue_executes_immediately(): + """Test that client without queue executes actions immediately.""" + client = OverkizClient( + server=Server.SOMFY_EUROPE, + credentials=UsernamePasswordCredentials("test@example.com", "test"), + action_queue_enabled=False, + ) + + action = Action( + device_url="io://1234-5678-9012/1", + commands=[Command(name=OverkizCommand.CLOSE)], + ) + + # Mock the internal execution + with patch.object( + client, "_OverkizClient__post", new_callable=AsyncMock + ) as mock_post: + mock_post.return_value = {"execId": "exec-123"} + + result = await client.execute_action_group([action]) + + # Should return exec_id directly (string) + assert isinstance(result, str) + assert result == "exec-123" + + # Should have called API immediately + mock_post.assert_called_once() + + await client.close() + + +@pytest.mark.asyncio +async def test_client_with_queue_batches_actions(): + """Test that client with queue batches multiple actions.""" + client = OverkizClient( + server=Server.SOMFY_EUROPE, + credentials=UsernamePasswordCredentials("test@example.com", "test"), + action_queue_enabled=True, + action_queue_delay=0.1, + ) + + actions = [ + Action( + device_url=f"io://1234-5678-9012/{i}", + commands=[Command(name=OverkizCommand.CLOSE)], + ) + for i in range(3) + ] + + with patch.object( + client, "_OverkizClient__post", new_callable=AsyncMock + ) as mock_post: + mock_post.return_value = {"execId": "exec-batched"} + + # Queue multiple actions quickly - start them as tasks to allow batching + task1 = asyncio.create_task(client.execute_action_group([actions[0]])) + task2 = asyncio.create_task(client.execute_action_group([actions[1]])) + task3 = asyncio.create_task(client.execute_action_group([actions[2]])) + + # Give them a moment to queue + await asyncio.sleep(0.01) + + # Should have 3 actions pending + assert client.get_pending_actions_count() == 3 + + # Wait for all to execute + exec_id1 = await task1 + exec_id2 = await task2 + exec_id3 = await task3 + + # All should have the same exec_id (batched together) + assert exec_id1 == exec_id2 == exec_id3 == "exec-batched" + + # Should have called API only once (batched) + mock_post.assert_called_once() + + # Check that all 3 actions were in the batch + call_args = mock_post.call_args + payload = call_args[0][1] # Second argument is the payload + assert len(payload["actions"]) == 3 + + await client.close() + + +@pytest.mark.asyncio +async def test_client_manual_flush(): + """Test manually flushing the queue.""" + client = OverkizClient( + server=Server.SOMFY_EUROPE, + credentials=UsernamePasswordCredentials("test@example.com", "test"), + action_queue_enabled=True, + action_queue_delay=10.0, # Long delay + ) + + action = Action( + device_url="io://1234-5678-9012/1", + commands=[Command(name=OverkizCommand.CLOSE)], + ) + + with patch.object( + client, "_OverkizClient__post", new_callable=AsyncMock + ) as mock_post: + mock_post.return_value = {"execId": "exec-flushed"} + + # Start execution as a task to allow checking pending count + exec_task = asyncio.create_task(client.execute_action_group([action])) + + # Give it a moment to queue + await asyncio.sleep(0.01) + + # Should have 1 action pending + assert client.get_pending_actions_count() == 1 + + # Manually flush + await client.flush_action_queue() + + # Should be executed now + assert client.get_pending_actions_count() == 0 + + exec_id = await exec_task + assert exec_id == "exec-flushed" + + mock_post.assert_called_once() + + await client.close() + + +@pytest.mark.asyncio +async def test_client_close_flushes_queue(): + """Test that closing the client flushes pending actions.""" + client = OverkizClient( + server=Server.SOMFY_EUROPE, + credentials=UsernamePasswordCredentials("test@example.com", "test"), + action_queue_enabled=True, + action_queue_delay=10.0, + ) + + action = Action( + device_url="io://1234-5678-9012/1", + commands=[Command(name=OverkizCommand.CLOSE)], + ) + + with patch.object( + client, "_OverkizClient__post", new_callable=AsyncMock + ) as mock_post: + mock_post.return_value = {"execId": "exec-closed"} + + # Start execution as a task + exec_task = asyncio.create_task(client.execute_action_group([action])) + + # Give it a moment to queue + await asyncio.sleep(0.01) + + # Close should flush + await client.close() + + # Should be executed + exec_id = await exec_task + assert exec_id == "exec-closed" + + mock_post.assert_called_once() + + +@pytest.mark.asyncio +async def test_client_queue_respects_max_actions(): + """Test that queue flushes when max actions is reached.""" + client = OverkizClient( + server=Server.SOMFY_EUROPE, + credentials=UsernamePasswordCredentials("test@example.com", "test"), + action_queue_enabled=True, + action_queue_delay=10.0, + action_queue_max_actions=2, # Max 2 actions + ) + + actions = [ + Action( + device_url=f"io://1234-5678-9012/{i}", + commands=[Command(name=OverkizCommand.CLOSE)], + ) + for i in range(3) + ] + + with patch.object( + client, "_OverkizClient__post", new_callable=AsyncMock + ) as mock_post: + mock_post.return_value = {"execId": "exec-123"} + + # Add 2 actions as tasks to trigger flush + task1 = asyncio.create_task(client.execute_action_group([actions[0]])) + task2 = asyncio.create_task(client.execute_action_group([actions[1]])) + + # Wait a bit for flush + await asyncio.sleep(0.05) + + # First 2 should be done + exec_id1 = await task1 + exec_id2 = await task2 + assert exec_id1 == "exec-123" + assert exec_id2 == "exec-123" + + # Add third action - starts new batch + exec_id3 = await client.execute_action_group([actions[2]]) + + # Should have exec_id directly (waited for batch to complete) + assert exec_id3 == "exec-123" + + # Should have been called twice (2 batches) + assert mock_post.call_count == 2 + + await client.close() diff --git a/tests/test_models.py b/tests/test_models.py index df99ff8b..5f76c32f 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -300,3 +300,33 @@ def test_bad_list_value(self): state = State(name="state", type=DataType.BOOLEAN, value=False) with pytest.raises(TypeError): assert state.value_as_list + + +def test_command_to_payload_omits_none(): + """Command.to_payload omits None fields from the resulting payload.""" + from pyoverkiz.enums.command import OverkizCommand + from pyoverkiz.models import Command + + cmd = Command(name=OverkizCommand.CLOSE, parameters=None, type=None) + payload = cmd.to_payload() + + assert payload == {"name": "close"} + + +def test_action_to_payload_and_parameters_conversion(): + """Action.to_payload converts nested Command enums to primitives.""" + from pyoverkiz.enums.command import OverkizCommand, OverkizCommandParam + from pyoverkiz.models import Action, Command + + cmd = Command( + name=OverkizCommand.SET_LEVEL, parameters=[10, OverkizCommandParam.A], type=1 + ) + action = Action("rts://2025-8464-6867/16756006", [cmd]) + + payload = action.to_payload() + + assert payload["device_url"] == "rts://2025-8464-6867/16756006" + assert payload["commands"][0]["name"] == "setLevel" + assert payload["commands"][0]["type"] == 1 + # parameters should be converted to primitives (enum -> str) + assert payload["commands"][0]["parameters"] == [10, "A"] diff --git a/tests/test_serializers.py b/tests/test_serializers.py new file mode 100644 index 00000000..603132e2 --- /dev/null +++ b/tests/test_serializers.py @@ -0,0 +1,37 @@ +"""Tests for pyoverkiz.serializers.""" + +from __future__ import annotations + +from pyoverkiz.serializers import prepare_payload + + +def test_prepare_payload_camelizes_and_fixes_device_url(): + """Test that prepare_payload converts snake_case to camelCase and fixes abbreviations.""" + payload = { + "label": "test", + "actions": [{"device_url": "rts://1/2", "commands": [{"name": "close"}]}], + } + + final = prepare_payload(payload) + + assert final["label"] == "test" + assert "deviceURL" in final["actions"][0] + assert final["actions"][0]["deviceURL"] == "rts://1/2" + + +def test_prepare_payload_nested_lists_and_dicts(): + """Test that prepare_payload handles nested lists and dicts correctly.""" + payload = { + "actions": [ + { + "device_url": "rts://1/2", + "commands": [{"name": "setLevel", "parameters": [10, "A"]}], + } + ] + } + + final = prepare_payload(payload) + + cmd = final["actions"][0]["commands"][0] + assert cmd["name"] == "setLevel" + assert cmd["parameters"] == [10, "A"] diff --git a/tests/test_utils.py b/tests/test_utils.py index a951078f..8b33b8f7 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,7 +2,7 @@ import pytest -from pyoverkiz.utils import generate_local_server, is_overkiz_gateway +from pyoverkiz.utils import create_local_server_config, is_overkiz_gateway LOCAL_HOST = "gateway-1234-5678-1243.local:8443" LOCAL_HOST_BY_IP = "192.168.1.105:8443" @@ -11,9 +11,9 @@ class TestUtils: """Tests for utility helpers like local server generation and gateway checks.""" - def test_generate_local_server(self): + def test_create_local_server_config(self): """Create a local server descriptor using the host and default values.""" - local_server = generate_local_server(host=LOCAL_HOST) + local_server = create_local_server_config(host=LOCAL_HOST) assert local_server assert ( @@ -24,9 +24,9 @@ def test_generate_local_server(self): assert local_server.name == "Somfy Developer Mode" assert local_server.configuration_url is None - def test_generate_local_server_by_ip(self): + def test_create_local_server_config_by_ip(self): """Create a local server descriptor using an IP host and custom fields.""" - local_server = generate_local_server( + local_server = create_local_server_config( host=LOCAL_HOST_BY_IP, manufacturer="Test Manufacturer", name="Test Name", diff --git a/uv.lock b/uv.lock index 6a30d575..d4eb8358 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.10, <4.0" +requires-python = ">=3.12, <4.0" [[package]] name = "aiohappyeyeballs" @@ -18,7 +18,6 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, { name = "aiosignal" }, - { name = "async-timeout", marker = "python_full_version < '3.11'" }, { name = "attrs" }, { name = "frozenlist" }, { name = "multidict" }, @@ -27,40 +26,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/1c/ce/3b83ebba6b3207a7135e5fcaba49706f8a4b6008153b4e30540c982fae26/aiohttp-3.13.2.tar.gz", hash = "sha256:40176a52c186aefef6eb3cad2cdd30cd06e3afbe88fe8ab2af9c0b90f228daca", size = 7837994, upload-time = "2025-10-28T20:59:39.937Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/34/939730e66b716b76046dedfe0842995842fa906ccc4964bba414ff69e429/aiohttp-3.13.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2372b15a5f62ed37789a6b383ff7344fc5b9f243999b0cd9b629d8bc5f5b4155", size = 736471, upload-time = "2025-10-28T20:55:27.924Z" }, - { url = "https://files.pythonhosted.org/packages/fd/cf/dcbdf2df7f6ca72b0bb4c0b4509701f2d8942cf54e29ca197389c214c07f/aiohttp-3.13.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e7f8659a48995edee7229522984bd1009c1213929c769c2daa80b40fe49a180c", size = 493985, upload-time = "2025-10-28T20:55:29.456Z" }, - { url = "https://files.pythonhosted.org/packages/9d/87/71c8867e0a1d0882dcbc94af767784c3cb381c1c4db0943ab4aae4fed65e/aiohttp-3.13.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:939ced4a7add92296b0ad38892ce62b98c619288a081170695c6babe4f50e636", size = 489274, upload-time = "2025-10-28T20:55:31.134Z" }, - { url = "https://files.pythonhosted.org/packages/38/0f/46c24e8dae237295eaadd113edd56dee96ef6462adf19b88592d44891dc5/aiohttp-3.13.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6315fb6977f1d0dd41a107c527fee2ed5ab0550b7d885bc15fee20ccb17891da", size = 1668171, upload-time = "2025-10-28T20:55:36.065Z" }, - { url = "https://files.pythonhosted.org/packages/eb/c6/4cdfb4440d0e28483681a48f69841fa5e39366347d66ef808cbdadddb20e/aiohttp-3.13.2-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6e7352512f763f760baaed2637055c49134fd1d35b37c2dedfac35bfe5cf8725", size = 1636036, upload-time = "2025-10-28T20:55:37.576Z" }, - { url = "https://files.pythonhosted.org/packages/84/37/8708cf678628216fb678ab327a4e1711c576d6673998f4f43e86e9ae90dd/aiohttp-3.13.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e09a0a06348a2dd73e7213353c90d709502d9786219f69b731f6caa0efeb46f5", size = 1727975, upload-time = "2025-10-28T20:55:39.457Z" }, - { url = "https://files.pythonhosted.org/packages/e6/2e/3ebfe12fdcb9b5f66e8a0a42dffcd7636844c8a018f261efb2419f68220b/aiohttp-3.13.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a09a6d073fb5789456545bdee2474d14395792faa0527887f2f4ec1a486a59d3", size = 1815823, upload-time = "2025-10-28T20:55:40.958Z" }, - { url = "https://files.pythonhosted.org/packages/a1/4f/ca2ef819488cbb41844c6cf92ca6dd15b9441e6207c58e5ae0e0fc8d70ad/aiohttp-3.13.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b59d13c443f8e049d9e94099c7e412e34610f1f49be0f230ec656a10692a5802", size = 1669374, upload-time = "2025-10-28T20:55:42.745Z" }, - { url = "https://files.pythonhosted.org/packages/f8/fe/1fe2e1179a0d91ce09c99069684aab619bf2ccde9b20bd6ca44f8837203e/aiohttp-3.13.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:20db2d67985d71ca033443a1ba2001c4b5693fe09b0e29f6d9358a99d4d62a8a", size = 1555315, upload-time = "2025-10-28T20:55:44.264Z" }, - { url = "https://files.pythonhosted.org/packages/5a/2b/f3781899b81c45d7cbc7140cddb8a3481c195e7cbff8e36374759d2ab5a5/aiohttp-3.13.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:960c2fc686ba27b535f9fd2b52d87ecd7e4fd1cf877f6a5cba8afb5b4a8bd204", size = 1639140, upload-time = "2025-10-28T20:55:46.626Z" }, - { url = "https://files.pythonhosted.org/packages/72/27/c37e85cd3ece6f6c772e549bd5a253d0c122557b25855fb274224811e4f2/aiohttp-3.13.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:6c00dbcf5f0d88796151e264a8eab23de2997c9303dd7c0bf622e23b24d3ce22", size = 1645496, upload-time = "2025-10-28T20:55:48.933Z" }, - { url = "https://files.pythonhosted.org/packages/66/20/3af1ab663151bd3780b123e907761cdb86ec2c4e44b2d9b195ebc91fbe37/aiohttp-3.13.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fed38a5edb7945f4d1bcabe2fcd05db4f6ec7e0e82560088b754f7e08d93772d", size = 1697625, upload-time = "2025-10-28T20:55:50.377Z" }, - { url = "https://files.pythonhosted.org/packages/95/eb/ae5cab15efa365e13d56b31b0d085a62600298bf398a7986f8388f73b598/aiohttp-3.13.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:b395bbca716c38bef3c764f187860e88c724b342c26275bc03e906142fc5964f", size = 1542025, upload-time = "2025-10-28T20:55:51.861Z" }, - { url = "https://files.pythonhosted.org/packages/e9/2d/1683e8d67ec72d911397fe4e575688d2a9b8f6a6e03c8fdc9f3fd3d4c03f/aiohttp-3.13.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:204ffff2426c25dfda401ba08da85f9c59525cdc42bda26660463dd1cbcfec6f", size = 1714918, upload-time = "2025-10-28T20:55:53.515Z" }, - { url = "https://files.pythonhosted.org/packages/99/a2/ffe8e0e1c57c5e542d47ffa1fcf95ef2b3ea573bf7c4d2ee877252431efc/aiohttp-3.13.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:05c4dd3c48fb5f15db31f57eb35374cb0c09afdde532e7fb70a75aede0ed30f6", size = 1656113, upload-time = "2025-10-28T20:55:55.438Z" }, - { url = "https://files.pythonhosted.org/packages/0d/42/d511aff5c3a2b06c09d7d214f508a4ad8ac7799817f7c3d23e7336b5e896/aiohttp-3.13.2-cp310-cp310-win32.whl", hash = "sha256:e574a7d61cf10351d734bcddabbe15ede0eaa8a02070d85446875dc11189a251", size = 432290, upload-time = "2025-10-28T20:55:56.96Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ea/1c2eb7098b5bad4532994f2b7a8228d27674035c9b3234fe02c37469ef14/aiohttp-3.13.2-cp310-cp310-win_amd64.whl", hash = "sha256:364f55663085d658b8462a1c3f17b2b84a5c2e1ba858e1b79bff7b2e24ad1514", size = 455075, upload-time = "2025-10-28T20:55:58.373Z" }, - { url = "https://files.pythonhosted.org/packages/35/74/b321e7d7ca762638cdf8cdeceb39755d9c745aff7a64c8789be96ddf6e96/aiohttp-3.13.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4647d02df098f6434bafd7f32ad14942f05a9caa06c7016fdcc816f343997dd0", size = 743409, upload-time = "2025-10-28T20:56:00.354Z" }, - { url = "https://files.pythonhosted.org/packages/99/3d/91524b905ec473beaf35158d17f82ef5a38033e5809fe8742e3657cdbb97/aiohttp-3.13.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e3403f24bcb9c3b29113611c3c16a2a447c3953ecf86b79775e7be06f7ae7ccb", size = 497006, upload-time = "2025-10-28T20:56:01.85Z" }, - { url = "https://files.pythonhosted.org/packages/eb/d3/7f68bc02a67716fe80f063e19adbd80a642e30682ce74071269e17d2dba1/aiohttp-3.13.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:43dff14e35aba17e3d6d5ba628858fb8cb51e30f44724a2d2f0c75be492c55e9", size = 493195, upload-time = "2025-10-28T20:56:03.314Z" }, - { url = "https://files.pythonhosted.org/packages/98/31/913f774a4708775433b7375c4f867d58ba58ead833af96c8af3621a0d243/aiohttp-3.13.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e2a9ea08e8c58bb17655630198833109227dea914cd20be660f52215f6de5613", size = 1747759, upload-time = "2025-10-28T20:56:04.904Z" }, - { url = "https://files.pythonhosted.org/packages/e8/63/04efe156f4326f31c7c4a97144f82132c3bb21859b7bb84748d452ccc17c/aiohttp-3.13.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53b07472f235eb80e826ad038c9d106c2f653584753f3ddab907c83f49eedead", size = 1704456, upload-time = "2025-10-28T20:56:06.986Z" }, - { url = "https://files.pythonhosted.org/packages/8e/02/4e16154d8e0a9cf4ae76f692941fd52543bbb148f02f098ca73cab9b1c1b/aiohttp-3.13.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e736c93e9c274fce6419af4aac199984d866e55f8a4cec9114671d0ea9688780", size = 1807572, upload-time = "2025-10-28T20:56:08.558Z" }, - { url = "https://files.pythonhosted.org/packages/34/58/b0583defb38689e7f06798f0285b1ffb3a6fb371f38363ce5fd772112724/aiohttp-3.13.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ff5e771f5dcbc81c64898c597a434f7682f2259e0cd666932a913d53d1341d1a", size = 1895954, upload-time = "2025-10-28T20:56:10.545Z" }, - { url = "https://files.pythonhosted.org/packages/6b/f3/083907ee3437425b4e376aa58b2c915eb1a33703ec0dc30040f7ae3368c6/aiohttp-3.13.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3b6fb0c207cc661fa0bf8c66d8d9b657331ccc814f4719468af61034b478592", size = 1747092, upload-time = "2025-10-28T20:56:12.118Z" }, - { url = "https://files.pythonhosted.org/packages/ac/61/98a47319b4e425cc134e05e5f3fc512bf9a04bf65aafd9fdcda5d57ec693/aiohttp-3.13.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:97a0895a8e840ab3520e2288db7cace3a1981300d48babeb50e7425609e2e0ab", size = 1606815, upload-time = "2025-10-28T20:56:14.191Z" }, - { url = "https://files.pythonhosted.org/packages/97/4b/e78b854d82f66bb974189135d31fce265dee0f5344f64dd0d345158a5973/aiohttp-3.13.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9e8f8afb552297aca127c90cb840e9a1d4bfd6a10d7d8f2d9176e1acc69bad30", size = 1723789, upload-time = "2025-10-28T20:56:16.101Z" }, - { url = "https://files.pythonhosted.org/packages/ed/fc/9d2ccc794fc9b9acd1379d625c3a8c64a45508b5091c546dea273a41929e/aiohttp-3.13.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ed2f9c7216e53c3df02264f25d824b079cc5914f9e2deba94155190ef648ee40", size = 1718104, upload-time = "2025-10-28T20:56:17.655Z" }, - { url = "https://files.pythonhosted.org/packages/66/65/34564b8765ea5c7d79d23c9113135d1dd3609173da13084830f1507d56cf/aiohttp-3.13.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:99c5280a329d5fa18ef30fd10c793a190d996567667908bef8a7f81f8202b948", size = 1785584, upload-time = "2025-10-28T20:56:19.238Z" }, - { url = "https://files.pythonhosted.org/packages/30/be/f6a7a426e02fc82781afd62016417b3948e2207426d90a0e478790d1c8a4/aiohttp-3.13.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ca6ffef405fc9c09a746cb5d019c1672cd7f402542e379afc66b370833170cf", size = 1595126, upload-time = "2025-10-28T20:56:20.836Z" }, - { url = "https://files.pythonhosted.org/packages/e5/c7/8e22d5d28f94f67d2af496f14a83b3c155d915d1fe53d94b66d425ec5b42/aiohttp-3.13.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:47f438b1a28e926c37632bff3c44df7d27c9b57aaf4e34b1def3c07111fdb782", size = 1800665, upload-time = "2025-10-28T20:56:22.922Z" }, - { url = "https://files.pythonhosted.org/packages/d1/11/91133c8b68b1da9fc16555706aa7276fdf781ae2bb0876c838dd86b8116e/aiohttp-3.13.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9acda8604a57bb60544e4646a4615c1866ee6c04a8edef9b8ee6fd1d8fa2ddc8", size = 1739532, upload-time = "2025-10-28T20:56:25.924Z" }, - { url = "https://files.pythonhosted.org/packages/17/6b/3747644d26a998774b21a616016620293ddefa4d63af6286f389aedac844/aiohttp-3.13.2-cp311-cp311-win32.whl", hash = "sha256:868e195e39b24aaa930b063c08bb0c17924899c16c672a28a65afded9c46c6ec", size = 431876, upload-time = "2025-10-28T20:56:27.524Z" }, - { url = "https://files.pythonhosted.org/packages/c3/63/688462108c1a00eb9f05765331c107f95ae86f6b197b865d29e930b7e462/aiohttp-3.13.2-cp311-cp311-win_amd64.whl", hash = "sha256:7fd19df530c292542636c2a9a85854fab93474396a52f1695e799186bbd7f24c", size = 456205, upload-time = "2025-10-28T20:56:29.062Z" }, { url = "https://files.pythonhosted.org/packages/29/9b/01f00e9856d0a73260e86dd8ed0c2234a466c5c1712ce1c281548df39777/aiohttp-3.13.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b1e56bab2e12b2b9ed300218c351ee2a3d8c8fdab5b1ec6193e11a817767e47b", size = 737623, upload-time = "2025-10-28T20:56:30.797Z" }, { url = "https://files.pythonhosted.org/packages/5a/1b/4be39c445e2b2bd0aab4ba736deb649fabf14f6757f405f0c9685019b9e9/aiohttp-3.13.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:364e25edaabd3d37b1db1f0cbcee8c73c9a3727bfa262b83e5e4cf3489a2a9dc", size = 492664, upload-time = "2025-10-28T20:56:32.708Z" }, { url = "https://files.pythonhosted.org/packages/28/66/d35dcfea8050e131cdd731dff36434390479b4045a8d0b9d7111b0a968f1/aiohttp-3.13.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c5c94825f744694c4b8db20b71dba9a257cd2ba8e010a803042123f3a25d50d7", size = 491808, upload-time = "2025-10-28T20:56:34.57Z" }, @@ -144,15 +109,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] -[[package]] -name = "async-timeout" -version = "5.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, -] - [[package]] name = "attrs" version = "25.4.0" @@ -171,24 +127,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, ] -[[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/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 = "backports-strenum" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/35/c7/2ed54c32fed313591ffb21edbd48db71e68827d43a61938e5a0bc2b6ec91/backports_strenum-1.3.1.tar.gz", hash = "sha256:77c52407342898497714f0596e86188bb7084f89063226f4ba66863482f42414", size = 7257, upload-time = "2023-12-09T14:36:40.937Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/50/56cf20e2ee5127b603b81d5a69580a1a325083e2b921aa8f067da83927c0/backports_strenum-1.3.1-py3-none-any.whl", hash = "sha256:cdcfe36dc897e2615dc793b7d3097f54d359918fc448754a517e6f23044ccf83", size = 8304, upload-time = "2023-12-09T14:36:39.905Z" }, -] - [[package]] name = "boto3" version = "1.42.17" @@ -259,32 +197,6 @@ version = "3.4.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/28/9901804da60055b406e1a1c5ba7aac1276fb77f1dde635aabfc7fd84b8ab/charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941", size = 201818, upload-time = "2025-05-02T08:31:46.725Z" }, - { url = "https://files.pythonhosted.org/packages/d9/9b/892a8c8af9110935e5adcbb06d9c6fe741b6bb02608c6513983048ba1a18/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd", size = 144649, upload-time = "2025-05-02T08:31:48.889Z" }, - { url = "https://files.pythonhosted.org/packages/7b/a5/4179abd063ff6414223575e008593861d62abfc22455b5d1a44995b7c101/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6", size = 155045, upload-time = "2025-05-02T08:31:50.757Z" }, - { url = "https://files.pythonhosted.org/packages/3b/95/bc08c7dfeddd26b4be8c8287b9bb055716f31077c8b0ea1cd09553794665/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d", size = 147356, upload-time = "2025-05-02T08:31:52.634Z" }, - { url = "https://files.pythonhosted.org/packages/a8/2d/7a5b635aa65284bf3eab7653e8b4151ab420ecbae918d3e359d1947b4d61/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86", size = 149471, upload-time = "2025-05-02T08:31:56.207Z" }, - { url = "https://files.pythonhosted.org/packages/ae/38/51fc6ac74251fd331a8cfdb7ec57beba8c23fd5493f1050f71c87ef77ed0/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c", size = 151317, upload-time = "2025-05-02T08:31:57.613Z" }, - { url = "https://files.pythonhosted.org/packages/b7/17/edee1e32215ee6e9e46c3e482645b46575a44a2d72c7dfd49e49f60ce6bf/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0", size = 146368, upload-time = "2025-05-02T08:31:59.468Z" }, - { url = "https://files.pythonhosted.org/packages/26/2c/ea3e66f2b5f21fd00b2825c94cafb8c326ea6240cd80a91eb09e4a285830/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef", size = 154491, upload-time = "2025-05-02T08:32:01.219Z" }, - { url = "https://files.pythonhosted.org/packages/52/47/7be7fa972422ad062e909fd62460d45c3ef4c141805b7078dbab15904ff7/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6", size = 157695, upload-time = "2025-05-02T08:32:03.045Z" }, - { url = "https://files.pythonhosted.org/packages/2f/42/9f02c194da282b2b340f28e5fb60762de1151387a36842a92b533685c61e/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366", size = 154849, upload-time = "2025-05-02T08:32:04.651Z" }, - { url = "https://files.pythonhosted.org/packages/67/44/89cacd6628f31fb0b63201a618049be4be2a7435a31b55b5eb1c3674547a/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db", size = 150091, upload-time = "2025-05-02T08:32:06.719Z" }, - { url = "https://files.pythonhosted.org/packages/1f/79/4b8da9f712bc079c0f16b6d67b099b0b8d808c2292c937f267d816ec5ecc/charset_normalizer-3.4.2-cp310-cp310-win32.whl", hash = "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a", size = 98445, upload-time = "2025-05-02T08:32:08.66Z" }, - { url = "https://files.pythonhosted.org/packages/7d/d7/96970afb4fb66497a40761cdf7bd4f6fca0fc7bafde3a84f836c1f57a926/charset_normalizer-3.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509", size = 105782, upload-time = "2025-05-02T08:32:10.46Z" }, - { url = "https://files.pythonhosted.org/packages/05/85/4c40d00dcc6284a1c1ad5de5e0996b06f39d8232f1031cd23c2f5c07ee86/charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2", size = 198794, upload-time = "2025-05-02T08:32:11.945Z" }, - { url = "https://files.pythonhosted.org/packages/41/d9/7a6c0b9db952598e97e93cbdfcb91bacd89b9b88c7c983250a77c008703c/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645", size = 142846, upload-time = "2025-05-02T08:32:13.946Z" }, - { url = "https://files.pythonhosted.org/packages/66/82/a37989cda2ace7e37f36c1a8ed16c58cf48965a79c2142713244bf945c89/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd", size = 153350, upload-time = "2025-05-02T08:32:15.873Z" }, - { url = "https://files.pythonhosted.org/packages/df/68/a576b31b694d07b53807269d05ec3f6f1093e9545e8607121995ba7a8313/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8", size = 145657, upload-time = "2025-05-02T08:32:17.283Z" }, - { url = "https://files.pythonhosted.org/packages/92/9b/ad67f03d74554bed3aefd56fe836e1623a50780f7c998d00ca128924a499/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f", size = 147260, upload-time = "2025-05-02T08:32:18.807Z" }, - { url = "https://files.pythonhosted.org/packages/a6/e6/8aebae25e328160b20e31a7e9929b1578bbdc7f42e66f46595a432f8539e/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7", size = 149164, upload-time = "2025-05-02T08:32:20.333Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f2/b3c2f07dbcc248805f10e67a0262c93308cfa149a4cd3d1fe01f593e5fd2/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9", size = 144571, upload-time = "2025-05-02T08:32:21.86Z" }, - { url = "https://files.pythonhosted.org/packages/60/5b/c3f3a94bc345bc211622ea59b4bed9ae63c00920e2e8f11824aa5708e8b7/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544", size = 151952, upload-time = "2025-05-02T08:32:23.434Z" }, - { url = "https://files.pythonhosted.org/packages/e2/4d/ff460c8b474122334c2fa394a3f99a04cf11c646da895f81402ae54f5c42/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82", size = 155959, upload-time = "2025-05-02T08:32:24.993Z" }, - { url = "https://files.pythonhosted.org/packages/a2/2b/b964c6a2fda88611a1fe3d4c400d39c66a42d6c169c924818c848f922415/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0", size = 153030, upload-time = "2025-05-02T08:32:26.435Z" }, - { url = "https://files.pythonhosted.org/packages/59/2e/d3b9811db26a5ebf444bc0fa4f4be5aa6d76fc6e1c0fd537b16c14e849b6/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5", size = 148015, upload-time = "2025-05-02T08:32:28.376Z" }, - { url = "https://files.pythonhosted.org/packages/90/07/c5fd7c11eafd561bb51220d600a788f1c8d77c5eef37ee49454cc5c35575/charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a", size = 98106, upload-time = "2025-05-02T08:32:30.281Z" }, - { url = "https://files.pythonhosted.org/packages/a8/05/5e33dbef7e2f773d672b6d79f10ec633d4a71cd96db6673625838a4fd532/charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28", size = 105402, upload-time = "2025-05-02T08:32:32.191Z" }, { url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936, upload-time = "2025-05-02T08:32:33.712Z" }, { url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790, upload-time = "2025-05-02T08:32:35.768Z" }, { url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924, upload-time = "2025-05-02T08:32:37.284Z" }, @@ -329,31 +241,6 @@ version = "7.13.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b6/45/2c665ca77ec32ad67e25c77daf1cee28ee4558f3bc571cdbaf88a00b9f23/coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936", size = 820905, upload-time = "2025-12-08T13:14:38.055Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/08/bdd7ccca14096f7eb01412b87ac11e5d16e4cb54b6e328afc9dee8bdaec1/coverage-7.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:02d9fb9eccd48f6843c98a37bd6817462f130b86da8660461e8f5e54d4c06070", size = 217979, upload-time = "2025-12-08T13:12:14.505Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f0/d1302e3416298a28b5663ae1117546a745d9d19fde7e28402b2c5c3e2109/coverage-7.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:367449cf07d33dc216c083f2036bb7d976c6e4903ab31be400ad74ad9f85ce98", size = 218496, upload-time = "2025-12-08T13:12:16.237Z" }, - { url = "https://files.pythonhosted.org/packages/07/26/d36c354c8b2a320819afcea6bffe72839efd004b98d1d166b90801d49d57/coverage-7.13.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cdb3c9f8fef0a954c632f64328a3935988d33a6604ce4bf67ec3e39670f12ae5", size = 245237, upload-time = "2025-12-08T13:12:17.858Z" }, - { url = "https://files.pythonhosted.org/packages/91/52/be5e85631e0eec547873d8b08dd67a5f6b111ecfe89a86e40b89b0c1c61c/coverage-7.13.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d10fd186aac2316f9bbb46ef91977f9d394ded67050ad6d84d94ed6ea2e8e54e", size = 247061, upload-time = "2025-12-08T13:12:19.132Z" }, - { url = "https://files.pythonhosted.org/packages/0f/45/a5e8fa0caf05fbd8fa0402470377bff09cc1f026d21c05c71e01295e55ab/coverage-7.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f88ae3e69df2ab62fb0bc5219a597cb890ba5c438190ffa87490b315190bb33", size = 248928, upload-time = "2025-12-08T13:12:20.702Z" }, - { url = "https://files.pythonhosted.org/packages/f5/42/ffb5069b6fd1b95fae482e02f3fecf380d437dd5a39bae09f16d2e2e7e01/coverage-7.13.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4be718e51e86f553bcf515305a158a1cd180d23b72f07ae76d6017c3cc5d791", size = 245931, upload-time = "2025-12-08T13:12:22.243Z" }, - { url = "https://files.pythonhosted.org/packages/95/6e/73e809b882c2858f13e55c0c36e94e09ce07e6165d5644588f9517efe333/coverage-7.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a00d3a393207ae12f7c49bb1c113190883b500f48979abb118d8b72b8c95c032", size = 246968, upload-time = "2025-12-08T13:12:23.52Z" }, - { url = "https://files.pythonhosted.org/packages/87/08/64ebd9e64b6adb8b4a4662133d706fbaccecab972e0b3ccc23f64e2678ad/coverage-7.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a7b1cd820e1b6116f92c6128f1188e7afe421c7e1b35fa9836b11444e53ebd9", size = 244972, upload-time = "2025-12-08T13:12:24.781Z" }, - { url = "https://files.pythonhosted.org/packages/12/97/f4d27c6fe0cb375a5eced4aabcaef22de74766fb80a3d5d2015139e54b22/coverage-7.13.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:37eee4e552a65866f15dedd917d5e5f3d59805994260720821e2c1b51ac3248f", size = 245241, upload-time = "2025-12-08T13:12:28.041Z" }, - { url = "https://files.pythonhosted.org/packages/0c/94/42f8ae7f633bf4c118bf1038d80472f9dade88961a466f290b81250f7ab7/coverage-7.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:62d7c4f13102148c78d7353c6052af6d899a7f6df66a32bddcc0c0eb7c5326f8", size = 245847, upload-time = "2025-12-08T13:12:29.337Z" }, - { url = "https://files.pythonhosted.org/packages/a8/2f/6369ca22b6b6d933f4f4d27765d313d8914cc4cce84f82a16436b1a233db/coverage-7.13.0-cp310-cp310-win32.whl", hash = "sha256:24e4e56304fdb56f96f80eabf840eab043b3afea9348b88be680ec5986780a0f", size = 220573, upload-time = "2025-12-08T13:12:30.905Z" }, - { url = "https://files.pythonhosted.org/packages/f1/dc/a6a741e519acceaeccc70a7f4cfe5d030efc4b222595f0677e101af6f1f3/coverage-7.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:74c136e4093627cf04b26a35dab8cbfc9b37c647f0502fc313376e11726ba303", size = 221509, upload-time = "2025-12-08T13:12:32.09Z" }, - { url = "https://files.pythonhosted.org/packages/f1/dc/888bf90d8b1c3d0b4020a40e52b9f80957d75785931ec66c7dfaccc11c7d/coverage-7.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0dfa3855031070058add1a59fdfda0192fd3e8f97e7c81de0596c145dea51820", size = 218104, upload-time = "2025-12-08T13:12:33.333Z" }, - { url = "https://files.pythonhosted.org/packages/8d/ea/069d51372ad9c380214e86717e40d1a743713a2af191cfba30a0911b0a4a/coverage-7.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fdb6f54f38e334db97f72fa0c701e66d8479af0bc3f9bfb5b90f1c30f54500f", size = 218606, upload-time = "2025-12-08T13:12:34.498Z" }, - { url = "https://files.pythonhosted.org/packages/68/09/77b1c3a66c2aa91141b6c4471af98e5b1ed9b9e6d17255da5eb7992299e3/coverage-7.13.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7e442c013447d1d8d195be62852270b78b6e255b79b8675bad8479641e21fd96", size = 248999, upload-time = "2025-12-08T13:12:36.02Z" }, - { url = "https://files.pythonhosted.org/packages/0a/32/2e2f96e9d5691eaf1181d9040f850b8b7ce165ea10810fd8e2afa534cef7/coverage-7.13.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ed5630d946859de835a85e9a43b721123a8a44ec26e2830b296d478c7fd4259", size = 250925, upload-time = "2025-12-08T13:12:37.221Z" }, - { url = "https://files.pythonhosted.org/packages/7b/45/b88ddac1d7978859b9a39a8a50ab323186148f1d64bc068f86fc77706321/coverage-7.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f15a931a668e58087bc39d05d2b4bf4b14ff2875b49c994bbdb1c2217a8daeb", size = 253032, upload-time = "2025-12-08T13:12:38.763Z" }, - { url = "https://files.pythonhosted.org/packages/71/cb/e15513f94c69d4820a34b6bf3d2b1f9f8755fa6021be97c7065442d7d653/coverage-7.13.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30a3a201a127ea57f7e14ba43c93c9c4be8b7d17a26e03bb49e6966d019eede9", size = 249134, upload-time = "2025-12-08T13:12:40.382Z" }, - { url = "https://files.pythonhosted.org/packages/09/61/d960ff7dc9e902af3310ce632a875aaa7860f36d2bc8fc8b37ee7c1b82a5/coverage-7.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a485ff48fbd231efa32d58f479befce52dcb6bfb2a88bb7bf9a0b89b1bc8030", size = 250731, upload-time = "2025-12-08T13:12:41.992Z" }, - { url = "https://files.pythonhosted.org/packages/98/34/c7c72821794afc7c7c2da1db8f00c2c98353078aa7fb6b5ff36aac834b52/coverage-7.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:22486cdafba4f9e471c816a2a5745337742a617fef68e890d8baf9f3036d7833", size = 248795, upload-time = "2025-12-08T13:12:43.331Z" }, - { url = "https://files.pythonhosted.org/packages/0a/5b/e0f07107987a43b2def9aa041c614ddb38064cbf294a71ef8c67d43a0cdd/coverage-7.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:263c3dbccc78e2e331e59e90115941b5f53e85cfcc6b3b2fbff1fd4e3d2c6ea8", size = 248514, upload-time = "2025-12-08T13:12:44.546Z" }, - { url = "https://files.pythonhosted.org/packages/71/c2/c949c5d3b5e9fc6dd79e1b73cdb86a59ef14f3709b1d72bf7668ae12e000/coverage-7.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5330fa0cc1f5c3c4c3bb8e101b742025933e7848989370a1d4c8c5e401ea753", size = 249424, upload-time = "2025-12-08T13:12:45.759Z" }, - { url = "https://files.pythonhosted.org/packages/11/f1/bbc009abd6537cec0dffb2cc08c17a7f03de74c970e6302db4342a6e05af/coverage-7.13.0-cp311-cp311-win32.whl", hash = "sha256:0f4872f5d6c54419c94c25dd6ae1d015deeb337d06e448cd890a1e89a8ee7f3b", size = 220597, upload-time = "2025-12-08T13:12:47.378Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f6/d9977f2fb51c10fbaed0718ce3d0a8541185290b981f73b1d27276c12d91/coverage-7.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51a202e0f80f241ccb68e3e26e19ab5b3bf0f813314f2c967642f13ebcf1ddfe", size = 221536, upload-time = "2025-12-08T13:12:48.7Z" }, - { url = "https://files.pythonhosted.org/packages/be/ad/3fcf43fd96fb43e337a3073dea63ff148dcc5c41ba7a14d4c7d34efb2216/coverage-7.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:d2a9d7f1c11487b1c69367ab3ac2d81b9b3721f097aa409a3191c3e90f8f3dd7", size = 220206, upload-time = "2025-12-08T13:12:50.365Z" }, { url = "https://files.pythonhosted.org/packages/9b/f1/2619559f17f31ba00fc40908efd1fbf1d0a5536eb75dc8341e7d660a08de/coverage-7.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0b3d67d31383c4c68e19a88e28fc4c2e29517580f1b0ebec4a069d502ce1e0bf", size = 218274, upload-time = "2025-12-08T13:12:52.095Z" }, { url = "https://files.pythonhosted.org/packages/2b/11/30d71ae5d6e949ff93b2a79a2c1b4822e00423116c5c6edfaeef37301396/coverage-7.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:581f086833d24a22c89ae0fe2142cfaa1c92c930adf637ddf122d55083fb5a0f", size = 218638, upload-time = "2025-12-08T13:12:53.418Z" }, { url = "https://files.pythonhosted.org/packages/79/c2/fce80fc6ded8d77e53207489d6065d0fed75db8951457f9213776615e0f5/coverage-7.13.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0a3a30f0e257df382f5f9534d4ce3d4cf06eafaf5192beb1a7bd066cb10e78fb", size = 250129, upload-time = "2025-12-08T13:12:54.744Z" }, @@ -422,11 +309,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8d/4c/1968f32fb9a2604645827e11ff84a31e59d532e01995f904723b4f5328b3/coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904", size = 210068, upload-time = "2025-12-08T13:14:36.236Z" }, ] -[package.optional-dependencies] -toml = [ - { name = "tomli", marker = "python_full_version <= '3.11'" }, -] - [[package]] name = "distlib" version = "0.3.9" @@ -457,18 +339,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/bc/f8c625a084b6074c2295f7eab967f868d424bb8ca30c7a656024b26fe04e/envs-1.4-py3-none-any.whl", hash = "sha256:4a1fcf85e4d4443e77c348ff7cdd3bfc4c0178b181d447057de342e4172e5ed1", size = 10988, upload-time = "2021-12-09T22:16:51.127Z" }, ] -[[package]] -name = "exceptiongroup" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -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 = "filelock" version = "3.20.0" @@ -484,40 +354,6 @@ version = "1.7.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/79/b1/b64018016eeb087db503b038296fd782586432b9c077fc5c7839e9cb6ef6/frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f", size = 45078, upload-time = "2025-06-09T23:02:35.538Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/36/0da0a49409f6b47cc2d060dc8c9040b897b5902a8a4e37d9bc1deb11f680/frozenlist-1.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cc4df77d638aa2ed703b878dd093725b72a824c3c546c076e8fdf276f78ee84a", size = 81304, upload-time = "2025-06-09T22:59:46.226Z" }, - { url = "https://files.pythonhosted.org/packages/77/f0/77c11d13d39513b298e267b22eb6cb559c103d56f155aa9a49097221f0b6/frozenlist-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:716a9973a2cc963160394f701964fe25012600f3d311f60c790400b00e568b61", size = 47735, upload-time = "2025-06-09T22:59:48.133Z" }, - { url = "https://files.pythonhosted.org/packages/37/12/9d07fa18971a44150593de56b2f2947c46604819976784bcf6ea0d5db43b/frozenlist-1.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0fd1bad056a3600047fb9462cff4c5322cebc59ebf5d0a3725e0ee78955001d", size = 46775, upload-time = "2025-06-09T22:59:49.564Z" }, - { url = "https://files.pythonhosted.org/packages/70/34/f73539227e06288fcd1f8a76853e755b2b48bca6747e99e283111c18bcd4/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3789ebc19cb811163e70fe2bd354cea097254ce6e707ae42e56f45e31e96cb8e", size = 224644, upload-time = "2025-06-09T22:59:51.35Z" }, - { url = "https://files.pythonhosted.org/packages/fb/68/c1d9c2f4a6e438e14613bad0f2973567586610cc22dcb1e1241da71de9d3/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af369aa35ee34f132fcfad5be45fbfcde0e3a5f6a1ec0712857f286b7d20cca9", size = 222125, upload-time = "2025-06-09T22:59:52.884Z" }, - { url = "https://files.pythonhosted.org/packages/b9/d0/98e8f9a515228d708344d7c6986752be3e3192d1795f748c24bcf154ad99/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac64b6478722eeb7a3313d494f8342ef3478dff539d17002f849101b212ef97c", size = 233455, upload-time = "2025-06-09T22:59:54.74Z" }, - { url = "https://files.pythonhosted.org/packages/79/df/8a11bcec5600557f40338407d3e5bea80376ed1c01a6c0910fcfdc4b8993/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f89f65d85774f1797239693cef07ad4c97fdd0639544bad9ac4b869782eb1981", size = 227339, upload-time = "2025-06-09T22:59:56.187Z" }, - { url = "https://files.pythonhosted.org/packages/50/82/41cb97d9c9a5ff94438c63cc343eb7980dac4187eb625a51bdfdb7707314/frozenlist-1.7.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1073557c941395fdfcfac13eb2456cb8aad89f9de27bae29fabca8e563b12615", size = 212969, upload-time = "2025-06-09T22:59:57.604Z" }, - { url = "https://files.pythonhosted.org/packages/13/47/f9179ee5ee4f55629e4f28c660b3fdf2775c8bfde8f9c53f2de2d93f52a9/frozenlist-1.7.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ed8d2fa095aae4bdc7fdd80351009a48d286635edffee66bf865e37a9125c50", size = 222862, upload-time = "2025-06-09T22:59:59.498Z" }, - { url = "https://files.pythonhosted.org/packages/1a/52/df81e41ec6b953902c8b7e3a83bee48b195cb0e5ec2eabae5d8330c78038/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:24c34bea555fe42d9f928ba0a740c553088500377448febecaa82cc3e88aa1fa", size = 222492, upload-time = "2025-06-09T23:00:01.026Z" }, - { url = "https://files.pythonhosted.org/packages/84/17/30d6ea87fa95a9408245a948604b82c1a4b8b3e153cea596421a2aef2754/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:69cac419ac6a6baad202c85aaf467b65ac860ac2e7f2ac1686dc40dbb52f6577", size = 238250, upload-time = "2025-06-09T23:00:03.401Z" }, - { url = "https://files.pythonhosted.org/packages/8f/00/ecbeb51669e3c3df76cf2ddd66ae3e48345ec213a55e3887d216eb4fbab3/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:960d67d0611f4c87da7e2ae2eacf7ea81a5be967861e0c63cf205215afbfac59", size = 218720, upload-time = "2025-06-09T23:00:05.282Z" }, - { url = "https://files.pythonhosted.org/packages/1a/c0/c224ce0e0eb31cc57f67742071bb470ba8246623c1823a7530be0e76164c/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:41be2964bd4b15bf575e5daee5a5ce7ed3115320fb3c2b71fca05582ffa4dc9e", size = 232585, upload-time = "2025-06-09T23:00:07.962Z" }, - { url = "https://files.pythonhosted.org/packages/55/3c/34cb694abf532f31f365106deebdeac9e45c19304d83cf7d51ebbb4ca4d1/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:46d84d49e00c9429238a7ce02dc0be8f6d7cd0cd405abd1bebdc991bf27c15bd", size = 234248, upload-time = "2025-06-09T23:00:09.428Z" }, - { url = "https://files.pythonhosted.org/packages/98/c0/2052d8b6cecda2e70bd81299e3512fa332abb6dcd2969b9c80dfcdddbf75/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:15900082e886edb37480335d9d518cec978afc69ccbc30bd18610b7c1b22a718", size = 221621, upload-time = "2025-06-09T23:00:11.32Z" }, - { url = "https://files.pythonhosted.org/packages/c5/bf/7dcebae315436903b1d98ffb791a09d674c88480c158aa171958a3ac07f0/frozenlist-1.7.0-cp310-cp310-win32.whl", hash = "sha256:400ddd24ab4e55014bba442d917203c73b2846391dd42ca5e38ff52bb18c3c5e", size = 39578, upload-time = "2025-06-09T23:00:13.526Z" }, - { url = "https://files.pythonhosted.org/packages/8f/5f/f69818f017fa9a3d24d1ae39763e29b7f60a59e46d5f91b9c6b21622f4cd/frozenlist-1.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:6eb93efb8101ef39d32d50bce242c84bcbddb4f7e9febfa7b524532a239b4464", size = 43830, upload-time = "2025-06-09T23:00:14.98Z" }, - { url = "https://files.pythonhosted.org/packages/34/7e/803dde33760128acd393a27eb002f2020ddb8d99d30a44bfbaab31c5f08a/frozenlist-1.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa51e147a66b2d74de1e6e2cf5921890de6b0f4820b257465101d7f37b49fb5a", size = 82251, upload-time = "2025-06-09T23:00:16.279Z" }, - { url = "https://files.pythonhosted.org/packages/75/a9/9c2c5760b6ba45eae11334db454c189d43d34a4c0b489feb2175e5e64277/frozenlist-1.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9b35db7ce1cd71d36ba24f80f0c9e7cff73a28d7a74e91fe83e23d27c7828750", size = 48183, upload-time = "2025-06-09T23:00:17.698Z" }, - { url = "https://files.pythonhosted.org/packages/47/be/4038e2d869f8a2da165f35a6befb9158c259819be22eeaf9c9a8f6a87771/frozenlist-1.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34a69a85e34ff37791e94542065c8416c1afbf820b68f720452f636d5fb990cd", size = 47107, upload-time = "2025-06-09T23:00:18.952Z" }, - { url = "https://files.pythonhosted.org/packages/79/26/85314b8a83187c76a37183ceed886381a5f992975786f883472fcb6dc5f2/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a646531fa8d82c87fe4bb2e596f23173caec9185bfbca5d583b4ccfb95183e2", size = 237333, upload-time = "2025-06-09T23:00:20.275Z" }, - { url = "https://files.pythonhosted.org/packages/1f/fd/e5b64f7d2c92a41639ffb2ad44a6a82f347787abc0c7df5f49057cf11770/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:79b2ffbba483f4ed36a0f236ccb85fbb16e670c9238313709638167670ba235f", size = 231724, upload-time = "2025-06-09T23:00:21.705Z" }, - { url = "https://files.pythonhosted.org/packages/20/fb/03395c0a43a5976af4bf7534759d214405fbbb4c114683f434dfdd3128ef/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a26f205c9ca5829cbf82bb2a84b5c36f7184c4316617d7ef1b271a56720d6b30", size = 245842, upload-time = "2025-06-09T23:00:23.148Z" }, - { url = "https://files.pythonhosted.org/packages/d0/15/c01c8e1dffdac5d9803507d824f27aed2ba76b6ed0026fab4d9866e82f1f/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bcacfad3185a623fa11ea0e0634aac7b691aa925d50a440f39b458e41c561d98", size = 239767, upload-time = "2025-06-09T23:00:25.103Z" }, - { url = "https://files.pythonhosted.org/packages/14/99/3f4c6fe882c1f5514b6848aa0a69b20cb5e5d8e8f51a339d48c0e9305ed0/frozenlist-1.7.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72c1b0fe8fe451b34f12dce46445ddf14bd2a5bcad7e324987194dc8e3a74c86", size = 224130, upload-time = "2025-06-09T23:00:27.061Z" }, - { url = "https://files.pythonhosted.org/packages/4d/83/220a374bd7b2aeba9d0725130665afe11de347d95c3620b9b82cc2fcab97/frozenlist-1.7.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61d1a5baeaac6c0798ff6edfaeaa00e0e412d49946c53fae8d4b8e8b3566c4ae", size = 235301, upload-time = "2025-06-09T23:00:29.02Z" }, - { url = "https://files.pythonhosted.org/packages/03/3c/3e3390d75334a063181625343e8daab61b77e1b8214802cc4e8a1bb678fc/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7edf5c043c062462f09b6820de9854bf28cc6cc5b6714b383149745e287181a8", size = 234606, upload-time = "2025-06-09T23:00:30.514Z" }, - { url = "https://files.pythonhosted.org/packages/23/1e/58232c19608b7a549d72d9903005e2d82488f12554a32de2d5fb59b9b1ba/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d50ac7627b3a1bd2dcef6f9da89a772694ec04d9a61b66cf87f7d9446b4a0c31", size = 248372, upload-time = "2025-06-09T23:00:31.966Z" }, - { url = "https://files.pythonhosted.org/packages/c0/a4/e4a567e01702a88a74ce8a324691e62a629bf47d4f8607f24bf1c7216e7f/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ce48b2fece5aeb45265bb7a58259f45027db0abff478e3077e12b05b17fb9da7", size = 229860, upload-time = "2025-06-09T23:00:33.375Z" }, - { url = "https://files.pythonhosted.org/packages/73/a6/63b3374f7d22268b41a9db73d68a8233afa30ed164c46107b33c4d18ecdd/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fe2365ae915a1fafd982c146754e1de6ab3478def8a59c86e1f7242d794f97d5", size = 245893, upload-time = "2025-06-09T23:00:35.002Z" }, - { url = "https://files.pythonhosted.org/packages/6d/eb/d18b3f6e64799a79673c4ba0b45e4cfbe49c240edfd03a68be20002eaeaa/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:45a6f2fdbd10e074e8814eb98b05292f27bad7d1883afbe009d96abdcf3bc898", size = 246323, upload-time = "2025-06-09T23:00:36.468Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f5/720f3812e3d06cd89a1d5db9ff6450088b8f5c449dae8ffb2971a44da506/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:21884e23cffabb157a9dd7e353779077bf5b8f9a58e9b262c6caad2ef5f80a56", size = 233149, upload-time = "2025-06-09T23:00:37.963Z" }, - { url = "https://files.pythonhosted.org/packages/69/68/03efbf545e217d5db8446acfd4c447c15b7c8cf4dbd4a58403111df9322d/frozenlist-1.7.0-cp311-cp311-win32.whl", hash = "sha256:284d233a8953d7b24f9159b8a3496fc1ddc00f4db99c324bd5fb5f22d8698ea7", size = 39565, upload-time = "2025-06-09T23:00:39.753Z" }, - { url = "https://files.pythonhosted.org/packages/58/17/fe61124c5c333ae87f09bb67186d65038834a47d974fc10a5fadb4cc5ae1/frozenlist-1.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:387cbfdcde2f2353f19c2f66bbb52406d06ed77519ac7ee21be0232147c2592d", size = 44019, upload-time = "2025-06-09T23:00:40.988Z" }, { url = "https://files.pythonhosted.org/packages/ef/a2/c8131383f1e66adad5f6ecfcce383d584ca94055a34d683bbb24ac5f2f1c/frozenlist-1.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3dbf9952c4bb0e90e98aec1bd992b3318685005702656bc6f67c1a32b76787f2", size = 81424, upload-time = "2025-06-09T23:00:42.24Z" }, { url = "https://files.pythonhosted.org/packages/4c/9d/02754159955088cb52567337d1113f945b9e444c4960771ea90eb73de8db/frozenlist-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f5906d3359300b8a9bb194239491122e6cf1444c2efb88865426f170c262cdb", size = 47952, upload-time = "2025-06-09T23:00:43.481Z" }, { url = "https://files.pythonhosted.org/packages/01/7a/0046ef1bd6699b40acd2067ed6d6670b4db2f425c56980fa21c982c2a9db/frozenlist-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3dabd5a8f84573c8d10d8859a50ea2dec01eea372031929871368c09fa103478", size = 46688, upload-time = "2025-06-09T23:00:44.793Z" }, @@ -614,27 +450,6 @@ version = "0.6.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/37/c3/cdff3c10e2e608490dc0a310ccf11ba777b3943ad4fcead2a2ade98c21e1/librt-0.6.3.tar.gz", hash = "sha256:c724a884e642aa2bbad52bb0203ea40406ad742368a5f90da1b220e970384aae", size = 54209, upload-time = "2025-11-29T14:01:56.058Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/84/859df8db21dedab2538ddfbe1d486dda3eb66a98c6ad7ba754a99e25e45e/librt-0.6.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:45660d26569cc22ed30adf583389d8a0d1b468f8b5e518fcf9bfe2cd298f9dd1", size = 27294, upload-time = "2025-11-29T14:00:35.053Z" }, - { url = "https://files.pythonhosted.org/packages/f7/01/ec3971cf9c4f827f17de6729bdfdbf01a67493147334f4ef8fac68936e3a/librt-0.6.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:54f3b2177fb892d47f8016f1087d21654b44f7fc4cf6571c1c6b3ea531ab0fcf", size = 27635, upload-time = "2025-11-29T14:00:36.496Z" }, - { url = "https://files.pythonhosted.org/packages/b4/f9/3efe201df84dd26388d2e0afa4c4dc668c8e406a3da7b7319152faf835a1/librt-0.6.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c5b31bed2c2f2fa1fcb4815b75f931121ae210dc89a3d607fb1725f5907f1437", size = 81768, upload-time = "2025-11-29T14:00:37.451Z" }, - { url = "https://files.pythonhosted.org/packages/0a/13/f63e60bc219b17f3d8f3d13423cd4972e597b0321c51cac7bfbdd5e1f7b9/librt-0.6.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f8ed5053ef9fb08d34f1fd80ff093ccbd1f67f147633a84cf4a7d9b09c0f089", size = 85884, upload-time = "2025-11-29T14:00:38.433Z" }, - { url = "https://files.pythonhosted.org/packages/c2/42/0068f14f39a79d1ce8a19d4988dd07371df1d0a7d3395fbdc8a25b1c9437/librt-0.6.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3f0e4bd9bcb0ee34fa3dbedb05570da50b285f49e52c07a241da967840432513", size = 85830, upload-time = "2025-11-29T14:00:39.418Z" }, - { url = "https://files.pythonhosted.org/packages/14/1c/87f5af3a9e6564f09e50c72f82fc3057fd42d1facc8b510a707d0438c4ad/librt-0.6.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d8f89c8d20dfa648a3f0a56861946eb00e5b00d6b00eea14bc5532b2fcfa8ef1", size = 88086, upload-time = "2025-11-29T14:00:40.555Z" }, - { url = "https://files.pythonhosted.org/packages/05/e5/22153b98b88a913b5b3f266f12e57df50a2a6960b3f8fcb825b1a0cfe40a/librt-0.6.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ecc2c526547eacd20cb9fbba19a5268611dbc70c346499656d6cf30fae328977", size = 86470, upload-time = "2025-11-29T14:00:41.827Z" }, - { url = "https://files.pythonhosted.org/packages/18/3c/ea1edb587799b1edcc22444e0630fa422e32d7aaa5bfb5115b948acc2d1c/librt-0.6.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fbedeb9b48614d662822ee514567d2d49a8012037fc7b4cd63f282642c2f4b7d", size = 89079, upload-time = "2025-11-29T14:00:42.882Z" }, - { url = "https://files.pythonhosted.org/packages/73/ad/50bb4ae6b07c9f3ab19653e0830a210533b30eb9a18d515efb5a2b9d0c7c/librt-0.6.3-cp310-cp310-win32.whl", hash = "sha256:0765b0fe0927d189ee14b087cd595ae636bef04992e03fe6dfdaa383866c8a46", size = 19820, upload-time = "2025-11-29T14:00:44.211Z" }, - { url = "https://files.pythonhosted.org/packages/7a/12/7426ee78f3b1dbe11a90619d54cb241ca924ca3c0ff9ade3992178e9b440/librt-0.6.3-cp310-cp310-win_amd64.whl", hash = "sha256:8c659f9fb8a2f16dc4131b803fa0144c1dadcb3ab24bb7914d01a6da58ae2457", size = 21332, upload-time = "2025-11-29T14:00:45.427Z" }, - { url = "https://files.pythonhosted.org/packages/8b/80/bc60fd16fe24910bf5974fb914778a2e8540cef55385ab2cb04a0dfe42c4/librt-0.6.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:61348cc488b18d1b1ff9f3e5fcd5ac43ed22d3e13e862489d2267c2337285c08", size = 27285, upload-time = "2025-11-29T14:00:46.626Z" }, - { url = "https://files.pythonhosted.org/packages/88/3c/26335536ed9ba097c79cffcee148393592e55758fe76d99015af3e47a6d0/librt-0.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:64645b757d617ad5f98c08e07620bc488d4bced9ced91c6279cec418f16056fa", size = 27629, upload-time = "2025-11-29T14:00:47.863Z" }, - { url = "https://files.pythonhosted.org/packages/af/fd/2dcedeacfedee5d2eda23e7a49c1c12ce6221b5d58a13555f053203faafc/librt-0.6.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:26b8026393920320bb9a811b691d73c5981385d537ffc5b6e22e53f7b65d4122", size = 82039, upload-time = "2025-11-29T14:00:49.131Z" }, - { url = "https://files.pythonhosted.org/packages/48/ff/6aa11914b83b0dc2d489f7636942a8e3322650d0dba840db9a1b455f3caa/librt-0.6.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d998b432ed9ffccc49b820e913c8f327a82026349e9c34fa3690116f6b70770f", size = 86560, upload-time = "2025-11-29T14:00:50.403Z" }, - { url = "https://files.pythonhosted.org/packages/76/a1/d25af61958c2c7eb978164aeba0350719f615179ba3f428b682b9a5fdace/librt-0.6.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e18875e17ef69ba7dfa9623f2f95f3eda6f70b536079ee6d5763ecdfe6cc9040", size = 86494, upload-time = "2025-11-29T14:00:51.383Z" }, - { url = "https://files.pythonhosted.org/packages/7d/4b/40e75d3b258c801908e64b39788f9491635f9554f8717430a491385bd6f2/librt-0.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a218f85081fc3f70cddaed694323a1ad7db5ca028c379c214e3a7c11c0850523", size = 88914, upload-time = "2025-11-29T14:00:52.688Z" }, - { url = "https://files.pythonhosted.org/packages/97/6d/0070c81aba8a169224301c75fb5fb6c3c25ca67e6ced086584fc130d5a67/librt-0.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1ef42ff4edd369e84433ce9b188a64df0837f4f69e3d34d3b34d4955c599d03f", size = 86944, upload-time = "2025-11-29T14:00:53.768Z" }, - { url = "https://files.pythonhosted.org/packages/a6/94/809f38887941b7726692e0b5a083dbdc87dbb8cf893e3b286550c5f0b129/librt-0.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e0f2b79993fec23a685b3e8107ba5f8675eeae286675a216da0b09574fa1e47", size = 89852, upload-time = "2025-11-29T14:00:54.71Z" }, - { url = "https://files.pythonhosted.org/packages/58/a3/b0e5b1cda675b91f1111d8ba941da455d8bfaa22f4d2d8963ba96ccb5b12/librt-0.6.3-cp311-cp311-win32.whl", hash = "sha256:fd98cacf4e0fabcd4005c452cb8a31750258a85cab9a59fb3559e8078da408d7", size = 19948, upload-time = "2025-11-29T14:00:55.989Z" }, - { url = "https://files.pythonhosted.org/packages/cc/73/70011c2b37e3be3ece3affd3abc8ebe5cda482b03fd6b3397906321a901e/librt-0.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:e17b5b42c8045867ca9d1f54af00cc2275198d38de18545edaa7833d7e9e4ac8", size = 21406, upload-time = "2025-11-29T14:00:56.874Z" }, - { url = "https://files.pythonhosted.org/packages/91/ee/119aa759290af6ca0729edf513ca390c1afbeae60f3ecae9b9d56f25a8a9/librt-0.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:87597e3d57ec0120a3e1d857a708f80c02c42ea6b00227c728efbc860f067c45", size = 20875, upload-time = "2025-11-29T14:00:57.752Z" }, { url = "https://files.pythonhosted.org/packages/b4/2c/b59249c566f98fe90e178baf59e83f628d6c38fb8bc78319301fccda0b5e/librt-0.6.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:74418f718083009108dc9a42c21bf2e4802d49638a1249e13677585fcc9ca176", size = 27841, upload-time = "2025-11-29T14:00:58.925Z" }, { url = "https://files.pythonhosted.org/packages/40/e8/9db01cafcd1a2872b76114c858f81cc29ce7ad606bc102020d6dabf470fb/librt-0.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:514f3f363d1ebc423357d36222c37e5c8e6674b6eae8d7195ac9a64903722057", size = 27844, upload-time = "2025-11-29T14:01:00.2Z" }, { url = "https://files.pythonhosted.org/packages/59/4d/da449d3a7d83cc853af539dee42adc37b755d7eea4ad3880bacfd84b651d/librt-0.6.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cf1115207a5049d1f4b7b4b72de0e52f228d6c696803d94843907111cbf80610", size = 84091, upload-time = "2025-11-29T14:01:01.118Z" }, @@ -685,47 +500,8 @@ wheels = [ name = "multidict" version = "6.5.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] sdist = { url = "https://files.pythonhosted.org/packages/46/b5/59f27b4ce9951a4bce56b88ba5ff5159486797ab18863f2b4c1c5e8465bd/multidict-6.5.0.tar.gz", hash = "sha256:942bd8002492ba819426a8d7aefde3189c1b87099cdf18aaaefefcf7f3f7b6d2", size = 98512, upload-time = "2025-06-17T14:15:56.556Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/88/f8354ef1cb1121234c3461ff3d11eac5f4fe115f00552d3376306275c9ab/multidict-6.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2e118a202904623b1d2606d1c8614e14c9444b59d64454b0c355044058066469", size = 73858, upload-time = "2025-06-17T14:13:21.451Z" }, - { url = "https://files.pythonhosted.org/packages/49/04/634b49c7abe71bd1c61affaeaa0c2a46b6be8d599a07b495259615dbdfe0/multidict-6.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a42995bdcaff4e22cb1280ae7752c3ed3fbb398090c6991a2797a4a0e5ed16a9", size = 43186, upload-time = "2025-06-17T14:13:23.615Z" }, - { url = "https://files.pythonhosted.org/packages/3b/ff/091ff4830ec8f96378578bfffa7f324a9dd16f60274cec861ae65ba10be3/multidict-6.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2261b538145723ca776e55208640fffd7ee78184d223f37c2b40b9edfe0e818a", size = 43031, upload-time = "2025-06-17T14:13:24.725Z" }, - { url = "https://files.pythonhosted.org/packages/10/c1/1b4137845f8b8dbc2332af54e2d7761c6a29c2c33c8d47a0c8c70676bac1/multidict-6.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e5b19f8cd67235fab3e195ca389490415d9fef5a315b1fa6f332925dc924262", size = 233588, upload-time = "2025-06-17T14:13:26.181Z" }, - { url = "https://files.pythonhosted.org/packages/c3/77/cbe9a1f58c6d4f822663788e414637f256a872bc352cedbaf7717b62db58/multidict-6.5.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:177b081e4dec67c3320b16b3aa0babc178bbf758553085669382c7ec711e1ec8", size = 222714, upload-time = "2025-06-17T14:13:27.482Z" }, - { url = "https://files.pythonhosted.org/packages/6c/37/39e1142c2916973818515adc13bbdb68d3d8126935e3855200e059a79bab/multidict-6.5.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d30a2cc106a7d116b52ee046207614db42380b62e6b1dd2a50eba47c5ca5eb1", size = 242741, upload-time = "2025-06-17T14:13:28.92Z" }, - { url = "https://files.pythonhosted.org/packages/a3/aa/60c3ef0c87ccad3445bf01926a1b8235ee24c3dde483faef1079cc91706d/multidict-6.5.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a72933bc308d7a64de37f0d51795dbeaceebdfb75454f89035cdfc6a74cfd129", size = 235008, upload-time = "2025-06-17T14:13:30.587Z" }, - { url = "https://files.pythonhosted.org/packages/bf/5e/f7e0fd5f5b8a7b9a75b0f5642ca6b6dde90116266920d8cf63b513f3908b/multidict-6.5.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96d109e663d032280ef8ef62b50924b2e887d5ddf19e301844a6cb7e91a172a6", size = 226627, upload-time = "2025-06-17T14:13:31.831Z" }, - { url = "https://files.pythonhosted.org/packages/b7/74/1bc0a3c6a9105051f68a6991fe235d7358836e81058728c24d5bbdd017cb/multidict-6.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b555329c9894332401f03b9a87016f0b707b6fccd4706793ec43b4a639e75869", size = 228232, upload-time = "2025-06-17T14:13:33.402Z" }, - { url = "https://files.pythonhosted.org/packages/99/e7/37118291cdc31f4cc680d54047cdea9b520e9a724a643919f71f8c2a2aeb/multidict-6.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6994bad9d471ef2156f2b6850b51e20ee409c6b9deebc0e57be096be9faffdce", size = 246616, upload-time = "2025-06-17T14:13:34.964Z" }, - { url = "https://files.pythonhosted.org/packages/ff/89/e2c08d6bdb21a1a55be4285510d058ace5f5acabe6b57900432e863d4c70/multidict-6.5.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:b15f817276c96cde9060569023808eec966bd8da56a97e6aa8116f34ddab6534", size = 235007, upload-time = "2025-06-17T14:13:36.428Z" }, - { url = "https://files.pythonhosted.org/packages/89/1e/e39a98e8e1477ec7a871b3c17265658fbe6d617048059ae7fa5011b224f3/multidict-6.5.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b4bf507c991db535a935b2127cf057a58dbc688c9f309c72080795c63e796f58", size = 244824, upload-time = "2025-06-17T14:13:37.982Z" }, - { url = "https://files.pythonhosted.org/packages/a3/ba/63e11edd45c31e708c5a1904aa7ac4de01e13135a04cfe96bc71eb359b85/multidict-6.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:60c3f8f13d443426c55f88cf3172547bbc600a86d57fd565458b9259239a6737", size = 257229, upload-time = "2025-06-17T14:13:39.554Z" }, - { url = "https://files.pythonhosted.org/packages/0f/00/bdcceb6af424936adfc8b92a79d3a95863585f380071393934f10a63f9e3/multidict-6.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a10227168a24420c158747fc201d4279aa9af1671f287371597e2b4f2ff21879", size = 247118, upload-time = "2025-06-17T14:13:40.795Z" }, - { url = "https://files.pythonhosted.org/packages/b6/a0/4aa79e991909cca36ca821a9ba5e8e81e4cd5b887c81f89ded994e0f49df/multidict-6.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e3b1425fe54ccfde66b8cfb25d02be34d5dfd2261a71561ffd887ef4088b4b69", size = 243948, upload-time = "2025-06-17T14:13:42.477Z" }, - { url = "https://files.pythonhosted.org/packages/21/8b/e45e19ce43afb31ff6b0fd5d5816b4fcc1fcc2f37e8a82aefae06c40c7a6/multidict-6.5.0-cp310-cp310-win32.whl", hash = "sha256:b4e47ef51237841d1087e1e1548071a6ef22e27ed0400c272174fa585277c4b4", size = 40433, upload-time = "2025-06-17T14:13:43.972Z" }, - { url = "https://files.pythonhosted.org/packages/d2/6e/96e0ba4601343d9344e69503fca072ace19c35f7d4ca3d68401e59acdc8f/multidict-6.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:63b3b24fadc7067282c88fae5b2f366d5b3a7c15c021c2838de8c65a50eeefb4", size = 44423, upload-time = "2025-06-17T14:13:44.991Z" }, - { url = "https://files.pythonhosted.org/packages/eb/4a/9befa919d7a390f13a5511a69282b7437782071160c566de6e0ebf712c9f/multidict-6.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:8b2d61afbafc679b7eaf08e9de4fa5d38bd5dc7a9c0a577c9f9588fb49f02dbb", size = 41481, upload-time = "2025-06-17T14:13:49.389Z" }, - { url = "https://files.pythonhosted.org/packages/75/ba/484f8e96ee58ec4fef42650eb9dbbedb24f9bc155780888398a4725d2270/multidict-6.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8b4bf6bb15a05796a07a248084e3e46e032860c899c7a9b981030e61368dba95", size = 73283, upload-time = "2025-06-17T14:13:50.406Z" }, - { url = "https://files.pythonhosted.org/packages/71/48/01d62ea6199d76934c87746695b3ed16aeedfdd564e8d89184577037baac/multidict-6.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46bb05d50219655c42a4b8fcda9c7ee658a09adbb719c48e65a20284e36328ea", size = 42937, upload-time = "2025-06-17T14:13:51.45Z" }, - { url = "https://files.pythonhosted.org/packages/da/cf/bb462d920f26d9e2e0aff8a78aeb06af1225b826e9a5468870c57591910a/multidict-6.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:54f524d73f4d54e87e03c98f6af601af4777e4668a52b1bd2ae0a4d6fc7b392b", size = 42748, upload-time = "2025-06-17T14:13:52.505Z" }, - { url = "https://files.pythonhosted.org/packages/cd/b1/d5c11ea0fdad68d3ed45f0e2527de6496d2fac8afe6b8ca6d407c20ad00f/multidict-6.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:529b03600466480ecc502000d62e54f185a884ed4570dee90d9a273ee80e37b5", size = 236448, upload-time = "2025-06-17T14:13:53.562Z" }, - { url = "https://files.pythonhosted.org/packages/fc/69/c3ceb264994f5b338c812911a8d660084f37779daef298fc30bd817f75c7/multidict-6.5.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69ad681ad7c93a41ee7005cc83a144b5b34a3838bcf7261e2b5356057b0f78de", size = 228695, upload-time = "2025-06-17T14:13:54.775Z" }, - { url = "https://files.pythonhosted.org/packages/81/3d/c23dcc0d34a35ad29974184db2878021d28fe170ecb9192be6bfee73f1f2/multidict-6.5.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fe9fada8bc0839466b09fa3f6894f003137942984843ec0c3848846329a36ae", size = 247434, upload-time = "2025-06-17T14:13:56.039Z" }, - { url = "https://files.pythonhosted.org/packages/06/b3/06cf7a049129ff52525a859277abb5648e61d7afae7fb7ed02e3806be34e/multidict-6.5.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f94c6ea6405fcf81baef1e459b209a78cda5442e61b5b7a57ede39d99b5204a0", size = 239431, upload-time = "2025-06-17T14:13:57.33Z" }, - { url = "https://files.pythonhosted.org/packages/8a/72/b2fe2fafa23af0c6123aebe23b4cd23fdad01dfe7009bb85624e4636d0dd/multidict-6.5.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84ca75ad8a39ed75f079a8931435a5b51ee4c45d9b32e1740f99969a5d1cc2ee", size = 231542, upload-time = "2025-06-17T14:13:58.597Z" }, - { url = "https://files.pythonhosted.org/packages/a1/c9/a52ca0a342a02411a31b6af197a6428a5137d805293f10946eeab614ec06/multidict-6.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:be4c08f3a2a6cc42b414496017928d95898964fed84b1b2dace0c9ee763061f9", size = 233069, upload-time = "2025-06-17T14:13:59.834Z" }, - { url = "https://files.pythonhosted.org/packages/9b/55/a3328a3929b8e131e2678d5e65f552b0a6874fab62123e31f5a5625650b0/multidict-6.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:046a7540cfbb4d5dc846a1fd9843f3ba980c6523f2e0c5b8622b4a5c94138ae6", size = 250596, upload-time = "2025-06-17T14:14:01.178Z" }, - { url = "https://files.pythonhosted.org/packages/6c/b8/aa3905a38a8287013aeb0a54c73f79ccd8b32d2f1d53e5934643a36502c2/multidict-6.5.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:64306121171d988af77d74be0d8c73ee1a69cf6f96aea7fa6030c88f32a152dd", size = 237858, upload-time = "2025-06-17T14:14:03.232Z" }, - { url = "https://files.pythonhosted.org/packages/d3/eb/f11d5af028014f402e5dd01ece74533964fa4e7bfae4af4824506fa8c398/multidict-6.5.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b4ac1dd5eb0ecf6f7351d5a9137f30a83f7182209c5d37f61614dfdce5714853", size = 249175, upload-time = "2025-06-17T14:14:04.561Z" }, - { url = "https://files.pythonhosted.org/packages/ac/57/d451905a62e5ef489cb4f92e8190d34ac5329427512afd7f893121da4e96/multidict-6.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bab4a8337235365f4111a7011a1f028826ca683834ebd12de4b85e2844359c36", size = 259532, upload-time = "2025-06-17T14:14:05.798Z" }, - { url = "https://files.pythonhosted.org/packages/d3/90/ff82b5ac5cabe3c79c50cf62a62f3837905aa717e67b6b4b7872804f23c8/multidict-6.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a05b5604c5a75df14a63eeeca598d11b2c3745b9008539b70826ea044063a572", size = 250554, upload-time = "2025-06-17T14:14:07.382Z" }, - { url = "https://files.pythonhosted.org/packages/d5/5a/0cabc50d4bc16e61d8b0a8a74499a1409fa7b4ef32970b7662a423781fc7/multidict-6.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:67c4a640952371c9ca65b6a710598be246ef3be5ca83ed38c16a7660d3980877", size = 248159, upload-time = "2025-06-17T14:14:08.65Z" }, - { url = "https://files.pythonhosted.org/packages/c0/1d/adeabae0771544f140d9f42ab2c46eaf54e793325999c36106078b7f6600/multidict-6.5.0-cp311-cp311-win32.whl", hash = "sha256:fdeae096ca36c12d8aca2640b8407a9d94e961372c68435bef14e31cce726138", size = 40357, upload-time = "2025-06-17T14:14:09.91Z" }, - { url = "https://files.pythonhosted.org/packages/e1/fe/bbd85ae65c96de5c9910c332ee1f4b7be0bf0fb21563895167bcb6502a1f/multidict-6.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:e2977ef8b7ce27723ee8c610d1bd1765da4f3fbe5a64f9bf1fd3b4770e31fbc0", size = 44432, upload-time = "2025-06-17T14:14:11.013Z" }, - { url = "https://files.pythonhosted.org/packages/96/af/f9052d9c4e65195b210da9f7afdea06d3b7592b3221cc0ef1b407f762faa/multidict-6.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:82d0cf0ea49bae43d9e8c3851e21954eff716259ff42da401b668744d1760bcb", size = 41408, upload-time = "2025-06-17T14:14:12.112Z" }, { url = "https://files.pythonhosted.org/packages/0a/fa/18f4950e00924f7e84c8195f4fc303295e14df23f713d64e778b8fa8b903/multidict-6.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1bb986c8ea9d49947bc325c51eced1ada6d8d9b4c5b15fd3fcdc3c93edef5a74", size = 73474, upload-time = "2025-06-17T14:14:13.528Z" }, { url = "https://files.pythonhosted.org/packages/6c/66/0392a2a8948bccff57e4793c9dde3e5c088f01e8b7f8867ee58a2f187fc5/multidict-6.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:03c0923da300120830fc467e23805d63bbb4e98b94032bd863bc7797ea5fa653", size = 43741, upload-time = "2025-06-17T14:14:15.188Z" }, { url = "https://files.pythonhosted.org/packages/98/3e/f48487c91b2a070566cfbab876d7e1ebe7deb0a8002e4e896a97998ae066/multidict-6.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4c78d5ec00fdd35c91680ab5cf58368faad4bd1a8721f87127326270248de9bc", size = 42143, upload-time = "2025-06-17T14:14:16.612Z" }, @@ -791,23 +567,10 @@ dependencies = [ { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, { name = "pathspec" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" }, - { url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" }, - { url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" }, - { url = "https://files.pythonhosted.org/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac", size = 10057200, upload-time = "2025-12-15T05:02:51.012Z" }, - { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, - { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, - { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, - { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, - { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, - { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, @@ -905,38 +668,6 @@ version = "0.3.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a6/16/43264e4a779dd8588c21a70f0709665ee8f611211bdd2c87d952cfa7c776/propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168", size = 44139, upload-time = "2025-06-09T22:56:06.081Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/14/510deed325e262afeb8b360043c5d7c960da7d3ecd6d6f9496c9c56dc7f4/propcache-0.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:22d9962a358aedbb7a2e36187ff273adeaab9743373a272976d2e348d08c7770", size = 73178, upload-time = "2025-06-09T22:53:40.126Z" }, - { url = "https://files.pythonhosted.org/packages/cd/4e/ad52a7925ff01c1325653a730c7ec3175a23f948f08626a534133427dcff/propcache-0.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0d0fda578d1dc3f77b6b5a5dce3b9ad69a8250a891760a548df850a5e8da87f3", size = 43133, upload-time = "2025-06-09T22:53:41.965Z" }, - { url = "https://files.pythonhosted.org/packages/63/7c/e9399ba5da7780871db4eac178e9c2e204c23dd3e7d32df202092a1ed400/propcache-0.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3def3da3ac3ce41562d85db655d18ebac740cb3fa4367f11a52b3da9d03a5cc3", size = 43039, upload-time = "2025-06-09T22:53:43.268Z" }, - { url = "https://files.pythonhosted.org/packages/22/e1/58da211eb8fdc6fc854002387d38f415a6ca5f5c67c1315b204a5d3e9d7a/propcache-0.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bec58347a5a6cebf239daba9bda37dffec5b8d2ce004d9fe4edef3d2815137e", size = 201903, upload-time = "2025-06-09T22:53:44.872Z" }, - { url = "https://files.pythonhosted.org/packages/c4/0a/550ea0f52aac455cb90111c8bab995208443e46d925e51e2f6ebdf869525/propcache-0.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55ffda449a507e9fbd4aca1a7d9aa6753b07d6166140e5a18d2ac9bc49eac220", size = 213362, upload-time = "2025-06-09T22:53:46.707Z" }, - { url = "https://files.pythonhosted.org/packages/5a/af/9893b7d878deda9bb69fcf54600b247fba7317761b7db11fede6e0f28bd0/propcache-0.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64a67fb39229a8a8491dd42f864e5e263155e729c2e7ff723d6e25f596b1e8cb", size = 210525, upload-time = "2025-06-09T22:53:48.547Z" }, - { url = "https://files.pythonhosted.org/packages/7c/bb/38fd08b278ca85cde36d848091ad2b45954bc5f15cce494bb300b9285831/propcache-0.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9da1cf97b92b51253d5b68cf5a2b9e0dafca095e36b7f2da335e27dc6172a614", size = 198283, upload-time = "2025-06-09T22:53:50.067Z" }, - { url = "https://files.pythonhosted.org/packages/78/8c/9fe55bd01d362bafb413dfe508c48753111a1e269737fa143ba85693592c/propcache-0.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5f559e127134b07425134b4065be45b166183fdcb433cb6c24c8e4149056ad50", size = 191872, upload-time = "2025-06-09T22:53:51.438Z" }, - { url = "https://files.pythonhosted.org/packages/54/14/4701c33852937a22584e08abb531d654c8bcf7948a8f87ad0a4822394147/propcache-0.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aff2e4e06435d61f11a428360a932138d0ec288b0a31dd9bd78d200bd4a2b339", size = 199452, upload-time = "2025-06-09T22:53:53.229Z" }, - { url = "https://files.pythonhosted.org/packages/16/44/447f2253d859602095356007657ee535e0093215ea0b3d1d6a41d16e5201/propcache-0.3.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4927842833830942a5d0a56e6f4839bc484785b8e1ce8d287359794818633ba0", size = 191567, upload-time = "2025-06-09T22:53:54.541Z" }, - { url = "https://files.pythonhosted.org/packages/f2/b3/e4756258749bb2d3b46defcff606a2f47410bab82be5824a67e84015b267/propcache-0.3.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6107ddd08b02654a30fb8ad7a132021759d750a82578b94cd55ee2772b6ebea2", size = 193015, upload-time = "2025-06-09T22:53:56.44Z" }, - { url = "https://files.pythonhosted.org/packages/1e/df/e6d3c7574233164b6330b9fd697beeac402afd367280e6dc377bb99b43d9/propcache-0.3.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:70bd8b9cd6b519e12859c99f3fc9a93f375ebd22a50296c3a295028bea73b9e7", size = 204660, upload-time = "2025-06-09T22:53:57.839Z" }, - { url = "https://files.pythonhosted.org/packages/b2/53/e4d31dd5170b4a0e2e6b730f2385a96410633b4833dc25fe5dffd1f73294/propcache-0.3.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2183111651d710d3097338dd1893fcf09c9f54e27ff1a8795495a16a469cc90b", size = 206105, upload-time = "2025-06-09T22:53:59.638Z" }, - { url = "https://files.pythonhosted.org/packages/7f/fe/74d54cf9fbe2a20ff786e5f7afcfde446588f0cf15fb2daacfbc267b866c/propcache-0.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fb075ad271405dcad8e2a7ffc9a750a3bf70e533bd86e89f0603e607b93aa64c", size = 196980, upload-time = "2025-06-09T22:54:01.071Z" }, - { url = "https://files.pythonhosted.org/packages/22/ec/c469c9d59dada8a7679625e0440b544fe72e99311a4679c279562051f6fc/propcache-0.3.2-cp310-cp310-win32.whl", hash = "sha256:404d70768080d3d3bdb41d0771037da19d8340d50b08e104ca0e7f9ce55fce70", size = 37679, upload-time = "2025-06-09T22:54:03.003Z" }, - { url = "https://files.pythonhosted.org/packages/38/35/07a471371ac89d418f8d0b699c75ea6dca2041fbda360823de21f6a9ce0a/propcache-0.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:7435d766f978b4ede777002e6b3b6641dd229cd1da8d3d3106a45770365f9ad9", size = 41459, upload-time = "2025-06-09T22:54:04.134Z" }, - { url = "https://files.pythonhosted.org/packages/80/8d/e8b436717ab9c2cfc23b116d2c297305aa4cd8339172a456d61ebf5669b8/propcache-0.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0b8d2f607bd8f80ddc04088bc2a037fdd17884a6fcadc47a96e334d72f3717be", size = 74207, upload-time = "2025-06-09T22:54:05.399Z" }, - { url = "https://files.pythonhosted.org/packages/d6/29/1e34000e9766d112171764b9fa3226fa0153ab565d0c242c70e9945318a7/propcache-0.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06766d8f34733416e2e34f46fea488ad5d60726bb9481d3cddf89a6fa2d9603f", size = 43648, upload-time = "2025-06-09T22:54:08.023Z" }, - { url = "https://files.pythonhosted.org/packages/46/92/1ad5af0df781e76988897da39b5f086c2bf0f028b7f9bd1f409bb05b6874/propcache-0.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2dc1f4a1df4fecf4e6f68013575ff4af84ef6f478fe5344317a65d38a8e6dc9", size = 43496, upload-time = "2025-06-09T22:54:09.228Z" }, - { url = "https://files.pythonhosted.org/packages/b3/ce/e96392460f9fb68461fabab3e095cb00c8ddf901205be4eae5ce246e5b7e/propcache-0.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be29c4f4810c5789cf10ddf6af80b041c724e629fa51e308a7a0fb19ed1ef7bf", size = 217288, upload-time = "2025-06-09T22:54:10.466Z" }, - { url = "https://files.pythonhosted.org/packages/c5/2a/866726ea345299f7ceefc861a5e782b045545ae6940851930a6adaf1fca6/propcache-0.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59d61f6970ecbd8ff2e9360304d5c8876a6abd4530cb752c06586849ac8a9dc9", size = 227456, upload-time = "2025-06-09T22:54:11.828Z" }, - { url = "https://files.pythonhosted.org/packages/de/03/07d992ccb6d930398689187e1b3c718339a1c06b8b145a8d9650e4726166/propcache-0.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62180e0b8dbb6b004baec00a7983e4cc52f5ada9cd11f48c3528d8cfa7b96a66", size = 225429, upload-time = "2025-06-09T22:54:13.823Z" }, - { url = "https://files.pythonhosted.org/packages/5d/e6/116ba39448753b1330f48ab8ba927dcd6cf0baea8a0ccbc512dfb49ba670/propcache-0.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c144ca294a204c470f18cf4c9d78887810d04a3e2fbb30eea903575a779159df", size = 213472, upload-time = "2025-06-09T22:54:15.232Z" }, - { url = "https://files.pythonhosted.org/packages/a6/85/f01f5d97e54e428885a5497ccf7f54404cbb4f906688a1690cd51bf597dc/propcache-0.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5c2a784234c28854878d68978265617aa6dc0780e53d44b4d67f3651a17a9a2", size = 204480, upload-time = "2025-06-09T22:54:17.104Z" }, - { url = "https://files.pythonhosted.org/packages/e3/79/7bf5ab9033b8b8194cc3f7cf1aaa0e9c3256320726f64a3e1f113a812dce/propcache-0.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5745bc7acdafa978ca1642891b82c19238eadc78ba2aaa293c6863b304e552d7", size = 214530, upload-time = "2025-06-09T22:54:18.512Z" }, - { url = "https://files.pythonhosted.org/packages/31/0b/bd3e0c00509b609317df4a18e6b05a450ef2d9a963e1d8bc9c9415d86f30/propcache-0.3.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c0075bf773d66fa8c9d41f66cc132ecc75e5bb9dd7cce3cfd14adc5ca184cb95", size = 205230, upload-time = "2025-06-09T22:54:19.947Z" }, - { url = "https://files.pythonhosted.org/packages/7a/23/fae0ff9b54b0de4e819bbe559508da132d5683c32d84d0dc2ccce3563ed4/propcache-0.3.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5f57aa0847730daceff0497f417c9de353c575d8da3579162cc74ac294c5369e", size = 206754, upload-time = "2025-06-09T22:54:21.716Z" }, - { url = "https://files.pythonhosted.org/packages/b7/7f/ad6a3c22630aaa5f618b4dc3c3598974a72abb4c18e45a50b3cdd091eb2f/propcache-0.3.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:eef914c014bf72d18efb55619447e0aecd5fb7c2e3fa7441e2e5d6099bddff7e", size = 218430, upload-time = "2025-06-09T22:54:23.17Z" }, - { url = "https://files.pythonhosted.org/packages/5b/2c/ba4f1c0e8a4b4c75910742f0d333759d441f65a1c7f34683b4a74c0ee015/propcache-0.3.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2a4092e8549031e82facf3decdbc0883755d5bbcc62d3aea9d9e185549936dcf", size = 223884, upload-time = "2025-06-09T22:54:25.539Z" }, - { url = "https://files.pythonhosted.org/packages/88/e4/ebe30fc399e98572019eee82ad0caf512401661985cbd3da5e3140ffa1b0/propcache-0.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85871b050f174bc0bfb437efbdb68aaf860611953ed12418e4361bc9c392749e", size = 211480, upload-time = "2025-06-09T22:54:26.892Z" }, - { url = "https://files.pythonhosted.org/packages/96/0a/7d5260b914e01d1d0906f7f38af101f8d8ed0dc47426219eeaf05e8ea7c2/propcache-0.3.2-cp311-cp311-win32.whl", hash = "sha256:36c8d9b673ec57900c3554264e630d45980fd302458e4ac801802a7fd2ef7897", size = 37757, upload-time = "2025-06-09T22:54:28.241Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2d/89fe4489a884bc0da0c3278c552bd4ffe06a1ace559db5ef02ef24ab446b/propcache-0.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53af8cb6a781b02d2ea079b5b853ba9430fcbe18a8e3ce647d5982a3ff69f39", size = 41500, upload-time = "2025-06-09T22:54:29.4Z" }, { url = "https://files.pythonhosted.org/packages/a8/42/9ca01b0a6f48e81615dca4765a8f1dd2c057e0540f6116a27dc5ee01dfb6/propcache-0.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8de106b6c84506b31c27168582cd3cb3000a6412c16df14a8628e5871ff83c10", size = 73674, upload-time = "2025-06-09T22:54:30.551Z" }, { url = "https://files.pythonhosted.org/packages/af/6e/21293133beb550f9c901bbece755d582bfaf2176bee4774000bd4dd41884/propcache-0.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:28710b0d3975117239c76600ea351934ac7b5ff56e60953474342608dbbb6154", size = 43570, upload-time = "2025-06-09T22:54:32.296Z" }, { url = "https://files.pythonhosted.org/packages/0c/c8/0393a0a3a2b8760eb3bde3c147f62b20044f0ddac81e9d6ed7318ec0d852/propcache-0.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce26862344bdf836650ed2487c3d724b00fbfec4233a1013f597b78c1cb73615", size = 43094, upload-time = "2025-06-09T22:54:33.929Z" }, @@ -1017,13 +748,12 @@ wheels = [ [[package]] name = "pyoverkiz" -version = "1.19.3" +version = "2.0.0" source = { editable = "." } dependencies = [ { name = "aiohttp" }, { name = "attrs" }, { name = "backoff" }, - { name = "backports-strenum", marker = "python_full_version < '3.11'" }, { name = "boto3" }, { name = "pyhumps" }, { name = "warrant-lite" }, @@ -1046,7 +776,6 @@ requires-dist = [ { name = "aiohttp", specifier = ">=3.10.3,<4.0.0" }, { name = "attrs", specifier = ">=21.2" }, { name = "backoff", specifier = ">=1.10.0,<3.0" }, - { name = "backports-strenum", marker = "python_full_version < '3.11'", specifier = ">=1.2.4,<2.0.0" }, { name = "boto3", specifier = ">=1.18.59,<2.0.0" }, { name = "pyhumps", specifier = ">=3.8.0,<4.0.0" }, { name = "warrant-lite", specifier = ">=1.0.4,<2.0.0" }, @@ -1070,7 +799,6 @@ version = "1.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/19/fd/437901c891f58a7b9096511750247535e891d2d5a5a6eefbc9386a2b41d5/pyproject_api-1.9.1.tar.gz", hash = "sha256:43c9918f49daab37e302038fc1aed54a8c7a91a9fa935d00b9a485f37e0f5335", size = 22710, upload-time = "2025-05-12T14:41:58.025Z" } wheels = [ @@ -1083,12 +811,10 @@ version = "9.0.2" 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/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } wheels = [ @@ -1100,7 +826,6 @@ name = "pytest-asyncio" 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'" }, ] @@ -1114,7 +839,7 @@ name = "pytest-cov" version = "7.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "coverage", extra = ["toml"] }, + { name = "coverage" }, { name = "pluggy" }, { name = "pytest" }, ] @@ -1155,24 +880,6 @@ version = "6.0.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199, upload-time = "2024-08-06T20:31:40.178Z" }, - { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758, upload-time = "2024-08-06T20:31:42.173Z" }, - { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463, upload-time = "2024-08-06T20:31:44.263Z" }, - { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280, upload-time = "2024-08-06T20:31:50.199Z" }, - { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239, upload-time = "2024-08-06T20:31:52.292Z" }, - { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802, upload-time = "2024-08-06T20:31:53.836Z" }, - { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527, upload-time = "2024-08-06T20:31:55.565Z" }, - { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052, upload-time = "2024-08-06T20:31:56.914Z" }, - { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774, upload-time = "2024-08-06T20:31:58.304Z" }, - { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload-time = "2024-08-06T20:32:03.408Z" }, - { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload-time = "2024-08-06T20:32:04.926Z" }, - { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload-time = "2024-08-06T20:32:06.459Z" }, - { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload-time = "2024-08-06T20:32:08.338Z" }, - { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload-time = "2024-08-06T20:32:14.124Z" }, - { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload-time = "2024-08-06T20:32:16.17Z" }, - { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload-time = "2024-08-06T20:32:18.555Z" }, - { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload-time = "2024-08-06T20:32:19.889Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload-time = "2024-08-06T20:32:21.273Z" }, { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, @@ -1267,55 +974,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] -[[package]] -name = "tomli" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, - { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, - { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, - { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, - { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, - { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, - { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, - { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, - { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, - { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, - { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, - { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, - { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, - { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, - { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, - { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, - { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, - { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, - { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, - { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, - { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, - { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, - { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, - { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, - { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, - { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, - { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, - { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, - { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, - { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, - { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, - { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, - { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, - { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, - { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, - { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, - { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, - { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, - { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, - { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, -] - [[package]] name = "tox" version = "4.32.0" @@ -1329,8 +987,6 @@ dependencies = [ { 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" } @@ -1389,7 +1045,6 @@ dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a4/d5/b0ccd381d55c8f45d46f77df6ae59fbc23d19e901e2d523395598e5f4c93/virtualenv-20.35.3.tar.gz", hash = "sha256:4f1a845d131133bdff10590489610c98c168ff99dc75d6c96853801f7f67af44", size = 6002907, upload-time = "2025-10-10T21:23:33.178Z" } wheels = [ @@ -1422,40 +1077,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/3c/fb/efaa23fa4e45537b827620f04cf8f3cd658b76642205162e072703a5b963/yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac", size = 186428, upload-time = "2025-06-10T00:46:09.923Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/65/7fed0d774abf47487c64be14e9223749468922817b5e8792b8a64792a1bb/yarl-1.20.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6032e6da6abd41e4acda34d75a816012717000fa6839f37124a47fcefc49bec4", size = 132910, upload-time = "2025-06-10T00:42:31.108Z" }, - { url = "https://files.pythonhosted.org/packages/8a/7b/988f55a52da99df9e56dc733b8e4e5a6ae2090081dc2754fc8fd34e60aa0/yarl-1.20.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2c7b34d804b8cf9b214f05015c4fee2ebe7ed05cf581e7192c06555c71f4446a", size = 90644, upload-time = "2025-06-10T00:42:33.851Z" }, - { url = "https://files.pythonhosted.org/packages/f7/de/30d98f03e95d30c7e3cc093759982d038c8833ec2451001d45ef4854edc1/yarl-1.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0c869f2651cc77465f6cd01d938d91a11d9ea5d798738c1dc077f3de0b5e5fed", size = 89322, upload-time = "2025-06-10T00:42:35.688Z" }, - { url = "https://files.pythonhosted.org/packages/e0/7a/f2f314f5ebfe9200724b0b748de2186b927acb334cf964fd312eb86fc286/yarl-1.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62915e6688eb4d180d93840cda4110995ad50c459bf931b8b3775b37c264af1e", size = 323786, upload-time = "2025-06-10T00:42:37.817Z" }, - { url = "https://files.pythonhosted.org/packages/15/3f/718d26f189db96d993d14b984ce91de52e76309d0fd1d4296f34039856aa/yarl-1.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:41ebd28167bc6af8abb97fec1a399f412eec5fd61a3ccbe2305a18b84fb4ca73", size = 319627, upload-time = "2025-06-10T00:42:39.937Z" }, - { url = "https://files.pythonhosted.org/packages/a5/76/8fcfbf5fa2369157b9898962a4a7d96764b287b085b5b3d9ffae69cdefd1/yarl-1.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21242b4288a6d56f04ea193adde174b7e347ac46ce6bc84989ff7c1b1ecea84e", size = 339149, upload-time = "2025-06-10T00:42:42.627Z" }, - { url = "https://files.pythonhosted.org/packages/3c/95/d7fc301cc4661785967acc04f54a4a42d5124905e27db27bb578aac49b5c/yarl-1.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bea21cdae6c7eb02ba02a475f37463abfe0a01f5d7200121b03e605d6a0439f8", size = 333327, upload-time = "2025-06-10T00:42:44.842Z" }, - { url = "https://files.pythonhosted.org/packages/65/94/e21269718349582eee81efc5c1c08ee71c816bfc1585b77d0ec3f58089eb/yarl-1.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f8a891e4a22a89f5dde7862994485e19db246b70bb288d3ce73a34422e55b23", size = 326054, upload-time = "2025-06-10T00:42:47.149Z" }, - { url = "https://files.pythonhosted.org/packages/32/ae/8616d1f07853704523519f6131d21f092e567c5af93de7e3e94b38d7f065/yarl-1.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd803820d44c8853a109a34e3660e5a61beae12970da479cf44aa2954019bf70", size = 315035, upload-time = "2025-06-10T00:42:48.852Z" }, - { url = "https://files.pythonhosted.org/packages/48/aa/0ace06280861ef055855333707db5e49c6e3a08840a7ce62682259d0a6c0/yarl-1.20.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b982fa7f74c80d5c0c7b5b38f908971e513380a10fecea528091405f519b9ebb", size = 338962, upload-time = "2025-06-10T00:42:51.024Z" }, - { url = "https://files.pythonhosted.org/packages/20/52/1e9d0e6916f45a8fb50e6844f01cb34692455f1acd548606cbda8134cd1e/yarl-1.20.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:33f29ecfe0330c570d997bcf1afd304377f2e48f61447f37e846a6058a4d33b2", size = 335399, upload-time = "2025-06-10T00:42:53.007Z" }, - { url = "https://files.pythonhosted.org/packages/f2/65/60452df742952c630e82f394cd409de10610481d9043aa14c61bf846b7b1/yarl-1.20.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:835ab2cfc74d5eb4a6a528c57f05688099da41cf4957cf08cad38647e4a83b30", size = 338649, upload-time = "2025-06-10T00:42:54.964Z" }, - { url = "https://files.pythonhosted.org/packages/7b/f5/6cd4ff38dcde57a70f23719a838665ee17079640c77087404c3d34da6727/yarl-1.20.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:46b5e0ccf1943a9a6e766b2c2b8c732c55b34e28be57d8daa2b3c1d1d4009309", size = 358563, upload-time = "2025-06-10T00:42:57.28Z" }, - { url = "https://files.pythonhosted.org/packages/d1/90/c42eefd79d0d8222cb3227bdd51b640c0c1d0aa33fe4cc86c36eccba77d3/yarl-1.20.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:df47c55f7d74127d1b11251fe6397d84afdde0d53b90bedb46a23c0e534f9d24", size = 357609, upload-time = "2025-06-10T00:42:59.055Z" }, - { url = "https://files.pythonhosted.org/packages/03/c8/cea6b232cb4617514232e0f8a718153a95b5d82b5290711b201545825532/yarl-1.20.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:76d12524d05841276b0e22573f28d5fbcb67589836772ae9244d90dd7d66aa13", size = 350224, upload-time = "2025-06-10T00:43:01.248Z" }, - { url = "https://files.pythonhosted.org/packages/ce/a3/eaa0ab9712f1f3d01faf43cf6f1f7210ce4ea4a7e9b28b489a2261ca8db9/yarl-1.20.1-cp310-cp310-win32.whl", hash = "sha256:6c4fbf6b02d70e512d7ade4b1f998f237137f1417ab07ec06358ea04f69134f8", size = 81753, upload-time = "2025-06-10T00:43:03.486Z" }, - { url = "https://files.pythonhosted.org/packages/8f/34/e4abde70a9256465fe31c88ed02c3f8502b7b5dead693a4f350a06413f28/yarl-1.20.1-cp310-cp310-win_amd64.whl", hash = "sha256:aef6c4d69554d44b7f9d923245f8ad9a707d971e6209d51279196d8e8fe1ae16", size = 86817, upload-time = "2025-06-10T00:43:05.231Z" }, - { url = "https://files.pythonhosted.org/packages/b1/18/893b50efc2350e47a874c5c2d67e55a0ea5df91186b2a6f5ac52eff887cd/yarl-1.20.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:47ee6188fea634bdfaeb2cc420f5b3b17332e6225ce88149a17c413c77ff269e", size = 133833, upload-time = "2025-06-10T00:43:07.393Z" }, - { url = "https://files.pythonhosted.org/packages/89/ed/b8773448030e6fc47fa797f099ab9eab151a43a25717f9ac043844ad5ea3/yarl-1.20.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0f6500f69e8402d513e5eedb77a4e1818691e8f45e6b687147963514d84b44b", size = 91070, upload-time = "2025-06-10T00:43:09.538Z" }, - { url = "https://files.pythonhosted.org/packages/e3/e3/409bd17b1e42619bf69f60e4f031ce1ccb29bd7380117a55529e76933464/yarl-1.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a8900a42fcdaad568de58887c7b2f602962356908eedb7628eaf6021a6e435b", size = 89818, upload-time = "2025-06-10T00:43:11.575Z" }, - { url = "https://files.pythonhosted.org/packages/f8/77/64d8431a4d77c856eb2d82aa3de2ad6741365245a29b3a9543cd598ed8c5/yarl-1.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bad6d131fda8ef508b36be3ece16d0902e80b88ea7200f030a0f6c11d9e508d4", size = 347003, upload-time = "2025-06-10T00:43:14.088Z" }, - { url = "https://files.pythonhosted.org/packages/8d/d2/0c7e4def093dcef0bd9fa22d4d24b023788b0a33b8d0088b51aa51e21e99/yarl-1.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:df018d92fe22aaebb679a7f89fe0c0f368ec497e3dda6cb81a567610f04501f1", size = 336537, upload-time = "2025-06-10T00:43:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/f0/f3/fc514f4b2cf02cb59d10cbfe228691d25929ce8f72a38db07d3febc3f706/yarl-1.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f969afbb0a9b63c18d0feecf0db09d164b7a44a053e78a7d05f5df163e43833", size = 362358, upload-time = "2025-06-10T00:43:18.704Z" }, - { url = "https://files.pythonhosted.org/packages/ea/6d/a313ac8d8391381ff9006ac05f1d4331cee3b1efaa833a53d12253733255/yarl-1.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:812303eb4aa98e302886ccda58d6b099e3576b1b9276161469c25803a8db277d", size = 357362, upload-time = "2025-06-10T00:43:20.888Z" }, - { url = "https://files.pythonhosted.org/packages/00/70/8f78a95d6935a70263d46caa3dd18e1f223cf2f2ff2037baa01a22bc5b22/yarl-1.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98c4a7d166635147924aa0bf9bfe8d8abad6fffa6102de9c99ea04a1376f91e8", size = 348979, upload-time = "2025-06-10T00:43:23.169Z" }, - { url = "https://files.pythonhosted.org/packages/cb/05/42773027968968f4f15143553970ee36ead27038d627f457cc44bbbeecf3/yarl-1.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12e768f966538e81e6e7550f9086a6236b16e26cd964cf4df35349970f3551cf", size = 337274, upload-time = "2025-06-10T00:43:27.111Z" }, - { url = "https://files.pythonhosted.org/packages/05/be/665634aa196954156741ea591d2f946f1b78ceee8bb8f28488bf28c0dd62/yarl-1.20.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe41919b9d899661c5c28a8b4b0acf704510b88f27f0934ac7a7bebdd8938d5e", size = 363294, upload-time = "2025-06-10T00:43:28.96Z" }, - { url = "https://files.pythonhosted.org/packages/eb/90/73448401d36fa4e210ece5579895731f190d5119c4b66b43b52182e88cd5/yarl-1.20.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8601bc010d1d7780592f3fc1bdc6c72e2b6466ea34569778422943e1a1f3c389", size = 358169, upload-time = "2025-06-10T00:43:30.701Z" }, - { url = "https://files.pythonhosted.org/packages/c3/b0/fce922d46dc1eb43c811f1889f7daa6001b27a4005587e94878570300881/yarl-1.20.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:daadbdc1f2a9033a2399c42646fbd46da7992e868a5fe9513860122d7fe7a73f", size = 362776, upload-time = "2025-06-10T00:43:32.51Z" }, - { url = "https://files.pythonhosted.org/packages/f1/0d/b172628fce039dae8977fd22caeff3eeebffd52e86060413f5673767c427/yarl-1.20.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:03aa1e041727cb438ca762628109ef1333498b122e4c76dd858d186a37cec845", size = 381341, upload-time = "2025-06-10T00:43:34.543Z" }, - { url = "https://files.pythonhosted.org/packages/6b/9b/5b886d7671f4580209e855974fe1cecec409aa4a89ea58b8f0560dc529b1/yarl-1.20.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:642980ef5e0fa1de5fa96d905c7e00cb2c47cb468bfcac5a18c58e27dbf8d8d1", size = 379988, upload-time = "2025-06-10T00:43:36.489Z" }, - { url = "https://files.pythonhosted.org/packages/73/be/75ef5fd0fcd8f083a5d13f78fd3f009528132a1f2a1d7c925c39fa20aa79/yarl-1.20.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:86971e2795584fe8c002356d3b97ef6c61862720eeff03db2a7c86b678d85b3e", size = 371113, upload-time = "2025-06-10T00:43:38.592Z" }, - { url = "https://files.pythonhosted.org/packages/50/4f/62faab3b479dfdcb741fe9e3f0323e2a7d5cd1ab2edc73221d57ad4834b2/yarl-1.20.1-cp311-cp311-win32.whl", hash = "sha256:597f40615b8d25812f14562699e287f0dcc035d25eb74da72cae043bb884d773", size = 81485, upload-time = "2025-06-10T00:43:41.038Z" }, - { url = "https://files.pythonhosted.org/packages/f0/09/d9c7942f8f05c32ec72cd5c8e041c8b29b5807328b68b4801ff2511d4d5e/yarl-1.20.1-cp311-cp311-win_amd64.whl", hash = "sha256:26ef53a9e726e61e9cd1cda6b478f17e350fb5800b4bd1cd9fe81c4d91cfeb2e", size = 86686, upload-time = "2025-06-10T00:43:42.692Z" }, { url = "https://files.pythonhosted.org/packages/5f/9a/cb7fad7d73c69f296eda6815e4a2c7ed53fc70c2f136479a91c8e5fbdb6d/yarl-1.20.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdcc4cd244e58593a4379fe60fdee5ac0331f8eb70320a24d591a3be197b94a9", size = 133667, upload-time = "2025-06-10T00:43:44.369Z" }, { url = "https://files.pythonhosted.org/packages/67/38/688577a1cb1e656e3971fb66a3492501c5a5df56d99722e57c98249e5b8a/yarl-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b29a2c385a5f5b9c7d9347e5812b6f7ab267193c62d282a540b4fc528c8a9d2a", size = 91025, upload-time = "2025-06-10T00:43:46.295Z" }, { url = "https://files.pythonhosted.org/packages/50/ec/72991ae51febeb11a42813fc259f0d4c8e0507f2b74b5514618d8b640365/yarl-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1112ae8154186dfe2de4732197f59c05a83dc814849a5ced892b708033f40dc2", size = 89709, upload-time = "2025-06-10T00:43:48.22Z" },