From 9aa5ddceaae9fad29ddd778016d4913f77e612ac Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Thu, 12 Mar 2026 19:04:16 +0100 Subject: [PATCH 001/200] Add optional query_params parameter to QueryMessage --- cassandra/protocol.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cassandra/protocol.py b/cassandra/protocol.py index f37633a756..4628c7ee0e 100644 --- a/cassandra/protocol.py +++ b/cassandra/protocol.py @@ -611,9 +611,10 @@ class QueryMessage(_QueryMessage): name = 'QUERY' def __init__(self, query, consistency_level, serial_consistency_level=None, - fetch_size=None, paging_state=None, timestamp=None, continuous_paging_options=None, keyspace=None): + fetch_size=None, paging_state=None, timestamp=None, continuous_paging_options=None, keyspace=None, + query_params=None): self.query = query - super(QueryMessage, self).__init__(None, consistency_level, serial_consistency_level, fetch_size, + super(QueryMessage, self).__init__(query_params, consistency_level, serial_consistency_level, fetch_size, paging_state, timestamp, False, continuous_paging_options, keyspace) def send_body(self, f, protocol_version): From 8bba6ebd361e4df959cc4f02dcbcb67201c6a526 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Fri, 13 Mar 2026 08:10:59 +0100 Subject: [PATCH 002/200] Introduce skip_scylla_version_lt for integration tests --- tests/integration/__init__.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py index dfac2dc1d9..a53e7aafa6 100644 --- a/tests/integration/__init__.py +++ b/tests/integration/__init__.py @@ -715,6 +715,27 @@ def xfail_scylla_version_lt(reason, oss_scylla_version, ent_scylla_version, *arg return pytest.mark.xfail(current_version < Version(oss_scylla_version), reason=reason, *args, **kwargs) + +def skip_scylla_version_lt(reason, scylla_version): + """ + Skip tests on scylla versions older than the specified thresholds. + :param reason: message explaining why the test is skipped + :param scylla_version: str, version from which test supposed to work + """ + if not (reason.startswith("scylladb/scylladb#") or reason.startswith("scylladb/scylla-enterprise#")): + raise ValueError('reason should start with scylladb/scylladb# or scylladb/scylla-enterprise# to reference issue in scylla repo') + + if not isinstance(scylla_version, str): + raise ValueError('scylla_version should be a str') + + if SCYLLA_VERSION is None: + return pytest.mark.skipif(False, reason="It is just a NoOP Decor, should not skip anything") + + current_version = Version(get_scylla_version(SCYLLA_VERSION)) + + return pytest.mark.skipif(current_version < Version(scylla_version), reason=reason) + + class UpDownWaiter(object): def __init__(self, host): From bc864c1b4e7e030c22aef539b622865e5e0fea95 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Tue, 17 Mar 2026 10:31:29 +0100 Subject: [PATCH 003/200] Add client routes data types and route store Introduce the data layer for Private Link client routes support: - ClientRoutesChangeType enum for CLIENT_ROUTES_CHANGE event types - ClientRouteProxy dataclass and ClientRoutesConfig for user-facing configuration - _Route frozen dataclass for immutable route records - _RouteStore for thread-safe route storage with atomic update/merge and preferred route selection that avoids unnecessary connection_id migration when multiple routes exist for the same host --- cassandra/client_routes.py | 192 +++++++++++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 cassandra/client_routes.py diff --git a/cassandra/client_routes.py b/cassandra/client_routes.py new file mode 100644 index 0000000000..f26aeef152 --- /dev/null +++ b/cassandra/client_routes.py @@ -0,0 +1,192 @@ +# Copyright 2026 ScyllaDB, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Client Routes support for Private Link and similar network configurations. + +This module implements support for dynamic address translation via the +system.client_routes table and CLIENT_ROUTES_CHANGE events. +""" + +from __future__ import absolute_import + +from dataclasses import dataclass +import enum +import logging +import threading +import uuid +from typing import Dict, List, Optional, Set + +log = logging.getLogger(__name__) + + +class ClientRoutesChangeType(enum.Enum): + """ + Types of CLIENT_ROUTES_CHANGE events. + + Currently the protocol defines only UPDATE_NODES. + New variants will be added here if the protocol is extended. + """ + UPDATE_NODES = "UPDATE_NODES" + + +@dataclass +class ClientRouteProxy: + """ + :param connection_id: String identifying the connection (required) + :param connection_addr_override:: Optional string address for initial connection + """ + + connection_id: str + connection_addr_override: Optional[str] = None + + def __post_init__(self): + if self.connection_id is None: + raise ValueError("connection_id is required") + +class ClientRoutesConfig: + """ + Configuration for client routes (Private Link support). + + :param proxies: List of :class:`ClientRouteProxy` objects + (REQUIRED, at least one) + :param advanced_shard_awareness: Whether to enable advanced shard awareness + (default: ``False``) + """ + + proxies: List[ClientRouteProxy] + advanced_shard_awareness: bool + + def __init__(self, proxies: List[ClientRouteProxy], advanced_shard_awareness: bool = False): + """ + :param proxies: List of ClientRouteProxy objects + :param advanced_shard_awareness: Enable advanced shard awareness (default False) + """ + if not proxies: + raise ValueError("At least one proxy must be specified") + + if not isinstance(proxies, (list, tuple)): + raise TypeError("proxies must be a list or tuple") + + for proxy in proxies: + if not isinstance(proxy, ClientRouteProxy): + raise TypeError("All proxies must be ClientRouteProxy instances") + + self.proxies = proxies + self.advanced_shard_awareness = advanced_shard_awareness + + def __repr__(self) -> str: + return (f"ClientRoutesConfig(proxies={self.proxies}, " + f"advanced_shard_awareness={self.advanced_shard_awareness})") + + +@dataclass(frozen=True) +class _Route: + connection_id: str + host_id: uuid.UUID + address: str # ipv4, ipv6 or DNS hostname from system.client_routes + port: int + +class _RouteStore: + """ + Thread-safe storage for routes. Reads are safe under CPython's GIL; + writes are serialized with a lock. + + This uses atomic pointer swaps for updates, allowing lock-free reads + while serializing writes. + """ + + _routes_by_host_id: Dict[uuid.UUID, _Route] + _lock: threading.Lock + + def __init__(self) -> None: + self._routes_by_host_id = {} + self._lock = threading.Lock() + + def get_by_host_id(self, host_id: uuid.UUID) -> Optional[_Route]: + """ + Get route for a host ID (lock-free read). + + :param host_id: UUID of the host + :return: _Route or None + """ + return self._routes_by_host_id.get(host_id) + + def get_all(self) -> List[_Route]: + """ + Get all routes as a list (lock-free read). + + :return: List of _Route + """ + return list(self._routes_by_host_id.values()) + + def _select_preferred_routes(self, new_routes: List[_Route]) -> List[_Route]: + """ + When multiple routes exist for the same host_id (different connection_ids), + prefer the connection_id already in use. Only migrate to a different + connection_id when the previously used one is no longer available. + + Must be called under self._lock. + """ + by_host: Dict[uuid.UUID, List[_Route]] = {} + for route in new_routes: + by_host.setdefault(route.host_id, []).append(route) + + selected = [] + for host_id, candidates in by_host.items(): + if len(candidates) == 1: + selected.append(candidates[0]) + continue + + existing = self._routes_by_host_id.get(host_id) + if existing: + preferred = [c for c in candidates if c.connection_id == existing.connection_id] + if preferred: + selected.append(preferred[0]) + continue + + selected.append(candidates[0]) + + return selected + + def update(self, routes: List[_Route]) -> None: + """ + Replace all routes atomically. + + :param routes: List of _Route objects + """ + with self._lock: + preferred = self._select_preferred_routes(routes) + self._routes_by_host_id = {route.host_id: route for route in preferred} + + def merge(self, new_routes: List[_Route], affected_host_ids: Set[uuid.UUID]) -> None: + """ + Merge new routes with existing ones atomically. + + Routes for affected_host_ids are replaced entirely: existing routes + for those hosts are dropped and replaced with whatever is in new_routes. + This handles deletions from system.client_routes (affected host present + but no new route for it). + + :param new_routes: List of _Route objects to merge + :param affected_host_ids: Set of host IDs affected by the change. + """ + with self._lock: + preferred = self._select_preferred_routes(new_routes) + new_by_host = {r.host_id: r for r in preferred} + + updated = {hid: r for hid, r in self._routes_by_host_id.items() + if hid not in affected_host_ids} + updated.update(new_by_host) + self._routes_by_host_id = updated From ac91295c85a8d176254d33bf79a62760ebf9056d Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Tue, 17 Mar 2026 10:32:17 +0100 Subject: [PATCH 004/200] Add client routes handler for Private Link support Add _ClientRoutesHandler which manages the full lifecycle of dynamic address translation via system.client_routes: - initialize(): loads all routes at startup and on control connection reconnect - handle_client_routes_change(): processes CLIENT_ROUTES_CHANGE events with targeted merge or full refresh depending on event data - _query_all_routes_for_connections(): complete refresh query using connection_id IN (...) - _query_routes_for_change_event(): targeted query grouping by connection_id with host_id IN (...) per group - _execute_routes_query(): common query execution and result parsing with proxy address override support - resolve_host(): host_id to (address, port) resolution with DNS lookup --- cassandra/client_routes.py | 261 ++++++++++++++++++++++++++++++++++++- 1 file changed, 260 insertions(+), 1 deletion(-) diff --git a/cassandra/client_routes.py b/cassandra/client_routes.py index f26aeef152..80b2477a6d 100644 --- a/cassandra/client_routes.py +++ b/cassandra/client_routes.py @@ -24,9 +24,17 @@ from dataclasses import dataclass import enum import logging +import socket import threading import uuid -from typing import Dict, List, Optional, Set +from typing import TYPE_CHECKING, Dict, List, Optional, Sequence, Set, Tuple + +from cassandra import ConsistencyLevel +from cassandra.protocol import QueryMessage +from cassandra.query import dict_factory + +if TYPE_CHECKING: + from cassandra.connection import Connection log = logging.getLogger(__name__) @@ -190,3 +198,254 @@ def merge(self, new_routes: List[_Route], affected_host_ids: Set[uuid.UUID]) -> if hid not in affected_host_ids} updated.update(new_by_host) self._routes_by_host_id = updated + + +class _ClientRoutesHandler: + """ + Handles dynamic address translation for Private Link via system.client_routes. + + Lifecycle: + 1. Construction: Create with configuration + 2. Initialization: Read system.client_routes after control connection established + 3. Steady state: Listen for CLIENT_ROUTES_CHANGE events and update routes + 4. Translation: Translate addresses using Host ID lookup + """ + + config: 'ClientRoutesConfig' + ssl_enabled: bool + _routes: _RouteStore + _connection_ids: Set[str] + _proxy_addresses_override: Dict[str, str] + + def __init__(self, config: 'ClientRoutesConfig', ssl_enabled: bool = False): + """ + :param config: ClientRoutesConfig instance + :param ssl_enabled: Whether TLS is enabled (determines port selection) + """ + if not isinstance(config, ClientRoutesConfig): + raise TypeError("config must be a ClientRoutesConfig instance") + + self.config = config + self.ssl_enabled = ssl_enabled + self._routes = _RouteStore() + self._connection_ids = {dep.connection_id for dep in config.proxies} + # Precalculate proxy address mappings for efficient lookup + self._proxy_addresses_override = { + proxy.connection_id: proxy.connection_addr_override + for proxy in config.proxies + if proxy.connection_addr_override + } + + def initialize(self, connection: 'Connection', timeout: float) -> None: + """ + Load all routes from system.client_routes. + + Called once at startup and again whenever the control connection + is re-established. Reads all configured connection IDs and + replaces the in-memory route store atomically. + + Raises on failure so the caller can decide how to react (e.g. + abort startup or schedule a reconnect). + + :param connection: The Connection instance to execute queries on + :param timeout: Query timeout in seconds + """ + log.info("[client routes] Loading routes for %d proxies", len(self.config.proxies)) + + routes = self._query_all_routes_for_connections(connection, timeout, self._connection_ids) + self._routes.update(routes) + + def handle_client_routes_change(self, connection: 'Connection', timeout: float, + change_type: 'ClientRoutesChangeType', + connection_ids: Sequence[str], host_ids: Sequence[str]) -> None: + """ + Handle CLIENT_ROUTES_CHANGE event. + + Currently the protocol defines only :attr:`ClientRoutesChangeType.UPDATE_NODES`. + New variants will be added to the enum if the protocol is extended. + + :param connection: The Connection instance to execute queries on + :param timeout: Query timeout in seconds + :param change_type: A :class:`ClientRoutesChangeType` value + :param connection_ids: Affected connection ID strings; empty means all. + :param host_ids: Affected host ID strings; empty means all. + """ + + full_refresh = False + if not connection_ids or not host_ids: + log.warning( + "[client routes] CLIENT_ROUTES_CHANGE has no connection_ids or host_ids, doing full refresh") + full_refresh = True + elif len(connection_ids) != len(host_ids): + log.warning("[client routes] CLIENT_ROUTES_CHANGE has mismatched lengths (conn: %d, host: %d), doing full refresh", + len(connection_ids), len(host_ids)) + full_refresh = True + + if full_refresh: + routes = self._query_all_routes_for_connections(connection, timeout, self._connection_ids) + self._routes.update(routes) + return + + host_uuids = [uuid.UUID(hid) for hid in host_ids] + pairs = [(cid, hid) for cid, hid in zip(connection_ids, host_uuids) + if cid in self._connection_ids] + + if not pairs: + return + + routes = self._query_routes_for_change_event(connection, timeout, pairs) + self._routes.merge(routes, affected_host_ids=set(host_uuids)) + + def _query_all_routes_for_connections(self, connection: 'Connection', timeout: float, + connection_ids: Set[str]) -> List[_Route]: + """ + Query all routes for the given connection IDs (complete refresh). + + Used when control connection reconnects or as a fallback when + CLIENT_ROUTES_CHANGE event has malformed data. + + :param connection: Connection to execute query on + :param timeout: Query timeout in seconds + :param connection_ids: Set of connection ID strings + :return: List of _Route + """ + if not connection_ids: + return [] + + placeholders = ', '.join('?' for _ in connection_ids) + query = f"SELECT connection_id, host_id, address, port, tls_port FROM system.client_routes WHERE connection_id IN ({placeholders})" + params = [cid.encode('utf-8') for cid in connection_ids] + + log.debug("[client routes] Querying all routes for connection_ids=%s", connection_ids) + return self._execute_routes_query(connection, timeout, query, params) + + def _query_routes_for_change_event(self, connection: 'Connection', timeout: float, + route_pairs: List[Tuple[str, uuid.UUID]]) -> List[_Route]: + """ + Query specific routes affected by a CLIENT_ROUTES_CHANGE event. + + Takes a list of (connection_id, host_id) pairs that represent the exact + routes affected by an operation. This provides precise updates without + fetching unrelated routes. + + If the pairs list is empty or None, falls back to a complete refresh + of all routes for safety. + + :param connection: Connection to execute query on + :param timeout: Query timeout in seconds + :param route_pairs: List of (connection_id, host_id) tuples + :return: List of _Route + """ + unique_pairs = list(dict.fromkeys(route_pairs)) + + conn_ids = list(dict.fromkeys(cid for cid, _ in unique_pairs)) + host_ids = list(dict.fromkeys(hid for _, hid in unique_pairs)) + + log.debug("[client routes] Querying route pairs from CLIENT_ROUTES_CHANGE " + "(first 5 of %d): %s", len(unique_pairs), unique_pairs[:5]) + + conn_ph = ', '.join('?' for _ in conn_ids) + host_ph = ', '.join('?' for _ in host_ids) + query = ( + "SELECT connection_id, host_id, address, port, tls_port " + "FROM system.client_routes " + f"WHERE connection_id IN ({conn_ph}) AND host_id IN ({host_ph})" + ) + params: List = [cid.encode('utf-8') for cid in conn_ids] + params.extend(hid.bytes for hid in host_ids) + + return self._execute_routes_query(connection, timeout, query, params) + + def _execute_routes_query(self, connection: 'Connection', timeout: float, + query: str, params: List) -> List[_Route]: + """ + Execute a routes query and parse results. + + Common helper for both complete refresh and change event queries. + + :param connection: Connection to execute query on + :param timeout: Query timeout in seconds + :param query: CQL query string + :param params: Query parameters + :return: List of _Route + """ + log.debug("[client routes] Executing query: %s with %d parameters", query, len(params)) + + query_msg = QueryMessage(query=query, consistency_level=ConsistencyLevel.ONE, + query_params=params if params else None) + result = connection.wait_for_response( + query_msg, timeout=timeout + ) + + routes = [] + broken = 0 + rows = dict_factory(result.column_names, result.parsed_rows) + for row in rows: + try: + absent = [] + port = row['tls_port'] if self.ssl_enabled else row['port'] + connection_id = row['connection_id'] + host_id = row['host_id'] + address = row['address'] + + if not port: + absent.append("tls_port" if self.ssl_enabled else "port") + if not connection_id: + absent.append("connection_id") + if not host_id: + absent.append("host_id") + if not address: + absent.append("address") + + if absent: + log.error("[client routes] read a route %s, that has no values for the following fields: %s", row, ",".join(absent)) + broken += 1 + continue + + final_address = self._proxy_addresses_override.get(connection_id, address) + + routes.append(_Route( + connection_id=connection_id, + host_id=host_id, + address=final_address, + port=port, + )) + except Exception as e: + log.warning("[client routes] Failed to parse route row: %s", e) + broken += 1 + + if broken and not routes: + raise RuntimeError( + "[client routes] All %d route rows failed validation; " + "refusing to return empty result that would wipe the route store" % broken + ) + + return routes + + def resolve_host(self, host_id: uuid.UUID) -> Optional[Tuple[str, int]]: + """ + Resolve a host_id to an (address, port) pair. + + Looks up the current route and selects the appropriate port. + + :param host_id: Host UUID to resolve + :return: Tuple of (address, port) or None if no route mapping exists + """ + route = self._routes.get_by_host_id(host_id) + if route is None: + return None + + if not route.port: + raise ValueError("Mapping for host %s has no port" % host_id) + + try: + result = socket.getaddrinfo(route.address, route.port, + socket.AF_UNSPEC, socket.SOCK_STREAM) + if not result: + raise socket.gaierror("No addresses found for %s" % route.address) + resolved_ip = result[0][4][0] + return resolved_ip, route.port + except socket.gaierror as e: + log.warning('[client routes] Could not resolve hostname "%s" (host_id=%s): %s', + route.address, host_id, e) + raise From 1e0e6ca1004f372c825fb88f6dc64183e80baa5f Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Tue, 10 Mar 2026 15:45:43 +0100 Subject: [PATCH 005/200] Add ClientRoutesEndPoint and ClientRoutesEndPointFactory - ClientRoutesEndPointFactory: creates endpoints from system.peers rows by extracting host_id, deferring address translation and DNS resolution until connection time - ClientRoutesEndPoint: endpoint that resolves via _ClientRoutesHandler on each connection attempt, ensuring immediate reaction to route changes and CLIENT_ROUTES_CHANGE events --- cassandra/connection.py | 120 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 118 insertions(+), 2 deletions(-) diff --git a/cassandra/connection.py b/cassandra/connection.py index 87f860f32b..72b273ec37 100644 --- a/cassandra/connection.py +++ b/cassandra/connection.py @@ -25,12 +25,14 @@ from threading import Thread, Event, RLock, Condition import time import ssl +import uuid import weakref import random import itertools -from typing import Optional, Union +from typing import Any, Dict, Optional, Tuple, Union from cassandra.application_info import ApplicationInfoBase +from cassandra.client_routes import _ClientRoutesHandler from cassandra.protocol_features import ProtocolFeatures if 'gevent.monkey' in sys.modules: @@ -230,7 +232,7 @@ class DefaultEndPointFactory(EndPointFactory): port = None """ If no port is discovered in the row, this is the default port - used for endpoint creation. + used for endpoint creation. """ def __init__(self, port=None): @@ -328,6 +330,50 @@ def create_from_sni(self, sni): return SniEndPoint(self._proxy_address, sni, self._port) +class ClientRoutesEndPointFactory(EndPointFactory): + """ + EndPointFactory for Client Routes (Private Link) support. + + Creates ClientRoutesEndPoint instances that defer both address translation + (host_id -> hostname lookup) and DNS resolution until connection time. + This ensures immediate reaction to infrastructure changes. + """ + + client_routes_handler: _ClientRoutesHandler + default_port: int + + def __init__(self, client_routes_handler: _ClientRoutesHandler, default_port: int = None) -> None: + """ + :param client_routes_handler: _ClientRoutesHandler instance to lookup routes + :param default_port: Default port if none found in row + """ + self.client_routes_handler = client_routes_handler + self.default_port = default_port + + def create(self, row: Dict[str, Any]) -> 'ClientRoutesEndPoint': + """ + Create a ClientRoutesEndPoint from a system.peers row. + + Stores only the host_id and handler reference. Both translation + (route lookup) and DNS resolution happen later in resolve(). + """ + from cassandra.metadata import _NodeInfo + host_id = row.get("host_id") + + if host_id is None: + raise ValueError("No host_id to create ClientRoutesEndPoint") + + addr = _NodeInfo.get_broadcast_rpc_address(row) + port = _NodeInfo.get_broadcast_rpc_port(row) or _NodeInfo.get_broadcast_port(row) or self.default_port + + return ClientRoutesEndPoint( + host_id=host_id, + handler=self.client_routes_handler, + original_address=addr, + original_port=port, + ) + + @total_ordering class UnixSocketEndPoint(EndPoint): """ @@ -369,6 +415,76 @@ def __repr__(self): return "<%s: %s>" % (self.__class__.__name__, self._unix_socket_path) +@total_ordering +class ClientRoutesEndPoint(EndPoint): + """ + Client Routes (Private Link) EndPoint implementation. + + Defers both address translation (route lookup) and DNS resolution + until resolve() is called at connection time. This ensures immediate + reaction to infrastructure changes and CLIENT_ROUTES_CHANGE events. + """ + + _host_id: uuid.UUID + _handler: _ClientRoutesHandler + _original_address: str + _original_port: int + + def __init__(self, host_id: uuid.UUID, handler: _ClientRoutesHandler, original_address: str, original_port: int = None) -> None: + """ + :param host_id: Host UUID for route lookup + :param handler: _ClientRoutesHandler instance + :param original_address: Original address from system.peers (for identification) + :param original_port: Original port if route doesn't specify one + """ + self._host_id = host_id + self._handler = handler + self._original_address = original_address + self._original_port = original_port + + @property + def address(self) -> str: + """Returns the original address (updated by resolve()).""" + return self._original_address + + @property + def port(self) -> Optional[int]: + return self._original_port + + @property + def host_id(self) -> uuid.UUID: + return self._host_id + + def resolve(self) -> Tuple[str, int]: + """ + Resolve endpoint by delegating to the handler. + Falls back to original address/port if no route mapping is available. + """ + result = self._handler.resolve_host(self._host_id) + if result is None: + return self._original_address, self._original_port + return result + + def __eq__(self, other): + return (isinstance(other, ClientRoutesEndPoint) and + self._host_id == other._host_id and + self._original_address == other._original_address) + + def __hash__(self): + return hash((self._host_id, self._original_address)) + + def __lt__(self, other): + return ((self._host_id, self._original_address) < + (other._host_id, other._original_address)) + + def __str__(self): + return str("%s (host_id=%s)" % (self._original_address, self._host_id)) + + def __repr__(self): + return "<%s: host_id=%s, original_addr=%s>" % ( + self.__class__.__name__, self._host_id, self._original_address) + + class _Frame(object): def __init__(self, version, flags, stream, opcode, body_offset, end_pos): self.version = version From 32023021bb16ada4c8425cf06bdf685f0169aefc Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Tue, 10 Mar 2026 15:49:20 +0100 Subject: [PATCH 006/200] Integrate client routes handler into Cluster and ControlConnection Cluster: - Add client_routes_config parameter with mutual exclusivity check against endpoint_factory - Create _ClientRoutesHandler and ClientRoutesEndPointFactory when client_routes_config is provided ControlConnection: - Register CLIENT_ROUTES_CHANGE event watcher when handler is present - Forward events to handler via _handle_client_routes_change - Trigger full route re-read on control connection reconnection --- cassandra/cluster.py | 103 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 98 insertions(+), 5 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 51d0b2d88b..8da9df6a55 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -29,7 +29,7 @@ from itertools import groupby, count, chain import json import logging -from typing import Optional, Union +from typing import Any, Dict, Optional, Union from warnings import warn from random import random import re @@ -48,7 +48,8 @@ SchemaTargetType, DriverException, ProtocolVersion, UnresolvableContactPoints, DependencyException) from cassandra.auth import _proxy_execute_key, PlainTextAuthProvider -from cassandra.connection import (ConnectionException, ConnectionShutdown, +from cassandra.client_routes import ClientRoutesChangeType, ClientRoutesConfig, _ClientRoutesHandler +from cassandra.connection import (ClientRoutesEndPointFactory, ConnectionException, ConnectionShutdown, ConnectionHeartbeat, ProtocolVersionUnsupported, EndPoint, DefaultEndPoint, DefaultEndPointFactory, SniEndPointFactory, ConnectionBusy, locally_supported_compressions) @@ -1215,7 +1216,8 @@ def __init__(self, shard_aware_options=None, metadata_request_timeout: Optional[float] = None, column_encryption_policy=None, - application_info:Optional[ApplicationInfoBase]=None + application_info:Optional[ApplicationInfoBase]=None, + client_routes_config:Optional[ClientRoutesConfig]=None ): """ ``executor_threads`` defines the number of threads in a pool for handling asynchronous tasks such as @@ -1280,6 +1282,45 @@ def __init__(self, if column_encryption_policy is not None: self.column_encryption_policy = column_encryption_policy + if client_routes_config is not None and endpoint_factory is not None: + raise ValueError("client_routes_config and endpoint_factory are mutually exclusive") + + self._client_routes_handler = None + if client_routes_config is not None: + if not isinstance(client_routes_config, ClientRoutesConfig): + raise TypeError("client_routes_config must be a ClientRoutesConfig instance") + + # SSL hostname verification is incompatible with client routes: + # connections go through NLB proxies whose addresses won't match + # server certificates. + _check_hostname_enabled = False + if ssl_context is not None and ssl_context.check_hostname: + _check_hostname_enabled = True + if ssl_options is not None and ssl_options.get('check_hostname', False): + _check_hostname_enabled = True + if _check_hostname_enabled: + raise ValueError( + "SSL hostname verification (check_hostname=True) is currently incompatible " + "with client_routes_config. When using client routes, connections " + "go through NLB proxies whose addresses won't match server " + "certificates. Disable hostname verification by setting " + "ssl_context.check_hostname = False." + ) + + ssl_enabled = ssl_context is not None or ssl_options is not None + self._client_routes_handler = _ClientRoutesHandler(client_routes_config, ssl_enabled=ssl_enabled) + + if contact_points is _NOT_SET or not self._contact_points_explicit: + seed_addrs = [dep.connection_addr_override for dep in client_routes_config.proxies + if dep.connection_addr_override] + if seed_addrs: + self.contact_points = seed_addrs + self._contact_points_explicit = True + log.info("[client routes] Using %d deployment connection addresses as contact points", + len(seed_addrs)) + + if self._client_routes_handler is not None: + endpoint_factory = ClientRoutesEndPointFactory(self._client_routes_handler, self.port) self.endpoint_factory = endpoint_factory or DefaultEndPointFactory(port=self.port) self.endpoint_factory.configure(self) @@ -1437,6 +1478,10 @@ def __init__(self, self.monitor_reporting_interval = monitor_reporting_interval self.shard_aware_options = ShardAwareOptions(opts=shard_aware_options) + if (client_routes_config is not None + and not client_routes_config.advanced_shard_awareness): + self.shard_aware_options.disable_shardaware_port = True + self._listeners = set() self._listener_lock = Lock() @@ -3612,11 +3657,21 @@ def _try_connect(self, endpoint): # this object (after a dereferencing a weakref) self_weakref = weakref.ref(self, partial(_clear_watcher, weakref.proxy(connection))) try: - connection.register_watchers({ + watchers = { "TOPOLOGY_CHANGE": partial(_watch_callback, self_weakref, '_handle_topology_change'), "STATUS_CHANGE": partial(_watch_callback, self_weakref, '_handle_status_change'), "SCHEMA_CHANGE": partial(_watch_callback, self_weakref, '_handle_schema_change') - }, register_timeout=self._timeout) + } + + if self._cluster._client_routes_handler is not None: + watchers["CLIENT_ROUTES_CHANGE"] = partial(_watch_callback, self_weakref, '_handle_client_routes_change') + + connection.register_watchers(watchers, register_timeout=self._timeout) + + if self._cluster._client_routes_handler is not None: + self._cluster._client_routes_handler.initialize( + connection, + self._timeout) sel_peers = self._get_peers_query(self.PeersQueryType.PEERS, connection) sel_local = self._SELECT_LOCAL if self._token_meta_enabled else self._SELECT_LOCAL_NO_TOKENS @@ -3979,6 +4034,44 @@ def _handle_status_change(self, event): # this will be run by the scheduler self._cluster.on_down(host, is_host_addition=False) + def _handle_client_routes_change(self, event: Dict[str, Any]) -> None: + """ + Handle CLIENT_ROUTES_CHANGE event from the server. + + This event indicates that the system.client_routes table has been updated + and we need to refresh our route mappings. + """ + if self._cluster._client_routes_handler is None: + log.warning("[control connection] Received CLIENT_ROUTES_CHANGE but no handler configured") + return + + raw_change_type = event.get("change_type") + try: + change_type = ClientRoutesChangeType(raw_change_type) + except ValueError: + log.warning("[control connection] Unknown CLIENT_ROUTES_CHANGE type: %s", raw_change_type) + return + + connection_ids = tuple(event.get("connection_ids", [])) + host_ids = tuple(event.get("host_ids", [])) + + self._cluster.scheduler.schedule_unique( + 0, + self._handle_client_routes_refresh, + self._connection, self._timeout, change_type, connection_ids, host_ids + ) + + def _handle_client_routes_refresh(self, connection, timeout, + change_type, connection_ids, host_ids): + try: + self._cluster._client_routes_handler.handle_client_routes_change( + connection, timeout, change_type, connection_ids, host_ids) + except ReferenceError: + pass # our weak reference to the Cluster is no good + except Exception: + log.debug("[control connection] Error handling CLIENT_ROUTES_CHANGE", exc_info=True) + self._signal_error() + def _handle_schema_change(self, event): if self._schema_event_refresh_window < 0: return From b205f838191c11ea630ff7829f0353e4e8f88be6 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Tue, 10 Mar 2026 15:57:52 +0100 Subject: [PATCH 007/200] tests: add unit tests for client routes Cover ClientRouteEntry/ClientRoutesConfig validation, _RouteStore get/merge operations, _ClientRoutesHandler initialization, ClientRoutesEndPoint resolution with and without route mappings, and SSL check_hostname rejection with client_routes_config. --- tests/unit/test_client_routes.py | 482 +++++++++++++++++++++++++++++++ 1 file changed, 482 insertions(+) create mode 100644 tests/unit/test_client_routes.py diff --git a/tests/unit/test_client_routes.py b/tests/unit/test_client_routes.py new file mode 100644 index 0000000000..0aa82fc76a --- /dev/null +++ b/tests/unit/test_client_routes.py @@ -0,0 +1,482 @@ +# Copyright 2026 ScyllaDB, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import socket +import ssl +import unittest +import uuid +from unittest.mock import Mock, patch + +from cassandra.client_routes import ( + ClientRouteProxy, + ClientRoutesChangeType, + ClientRoutesConfig, + _RouteStore, + _Route, + _ClientRoutesHandler +) +from cassandra.connection import ClientRoutesEndPoint, ClientRoutesEndPointFactory +from cassandra.cluster import Cluster + + +class TestClientRouteProxy(unittest.TestCase): + + def test_endpoint_none_connection_id(self): + with self.assertRaises(ValueError): + ClientRouteProxy(None) + + +class TestClientRoutesConfig(unittest.TestCase): + + def test_config_with_proxies(self): + ep1 = ClientRouteProxy(str(uuid.uuid4()), "10.0.0.1") + ep2 = ClientRouteProxy(str(uuid.uuid4()), "10.0.0.2") + config = ClientRoutesConfig([ep1, ep2]) + self.assertEqual(len(config.proxies), 2) + + def test_config_empty_proxies(self): + with self.assertRaises(ValueError): + ClientRoutesConfig([]) + + def test_config_invalid_proxy_type(self): + with self.assertRaises(TypeError): + ClientRoutesConfig(["not-a-proxy"]) + + + +class TestRouteStore(unittest.TestCase): + + def test_get_by_host_id(self): + routes = _RouteStore() + host_id = uuid.uuid4() + route = _Route( + connection_id=str(uuid.uuid4()), + host_id=host_id, + address="example.com", + port=9042, + ) + + routes.update([route]) + + retrieved = routes.get_by_host_id(host_id) + self.assertEqual(retrieved.host_id, host_id) + self.assertEqual(retrieved.address, "example.com") + + def test_merge_routes(self): + routes = _RouteStore() + host_id1 = uuid.uuid4() + host_id2 = uuid.uuid4() + + route1 = _Route( + connection_id=str(uuid.uuid4()), host_id=host_id1, + address="host1.com", port=9042, + ) + + route2 = _Route( + connection_id=str(uuid.uuid4()), host_id=host_id2, + address="host2.com", port=9042, + ) + + routes.update([route1]) + routes.merge([route2], affected_host_ids={host_id2}) + + self.assertIsNotNone(routes.get_by_host_id(host_id1)) + self.assertIsNotNone(routes.get_by_host_id(host_id2)) + + def test_merge_deletes_affected_host_with_no_new_route(self): + """When an affected host_id has no corresponding new route, it should be removed.""" + store = _RouteStore() + host_id1 = uuid.uuid4() + host_id2 = uuid.uuid4() + conn_id = str(uuid.uuid4()) + + store.update([ + _Route(connection_id=conn_id, host_id=host_id1, address="a.com", port=9042), + _Route(connection_id=conn_id, host_id=host_id2, address="b.com", port=9042), + ]) + self.assertIsNotNone(store.get_by_host_id(host_id1)) + self.assertIsNotNone(store.get_by_host_id(host_id2)) + + # Merge with host_id2 affected but no new route for it → deletion + store.merge([], affected_host_ids={host_id2}) + + self.assertIsNotNone(store.get_by_host_id(host_id1)) + self.assertIsNone(store.get_by_host_id(host_id2)) + + def test_select_preferred_routes_keeps_existing_connection_id(self): + """When multiple connection_ids provide routes for the same host_id, + the one already in use should be preferred.""" + store = _RouteStore() + host_id = uuid.uuid4() + conn_a = "conn-a" + conn_b = "conn-b" + + # Populate store with conn_a for host_id + store.update([_Route(connection_id=conn_a, host_id=host_id, address="a.com", port=9042)]) + self.assertEqual(store.get_by_host_id(host_id).connection_id, conn_a) + + # Update with both conn_a and conn_b for the same host_id + store.update([ + _Route(connection_id=conn_b, host_id=host_id, address="b.com", port=9042), + _Route(connection_id=conn_a, host_id=host_id, address="a-new.com", port=9042), + ]) + # conn_a should be preferred since it was already in use + result = store.get_by_host_id(host_id) + self.assertEqual(result.connection_id, conn_a) + self.assertEqual(result.address, "a-new.com") + + def test_select_preferred_routes_falls_back_when_existing_gone(self): + """When the existing connection_id is no longer among candidates, + the first candidate should be selected.""" + store = _RouteStore() + host_id = uuid.uuid4() + + store.update([_Route(connection_id="old-conn", host_id=host_id, address="old.com", port=9042)]) + + # Update only has new connection_ids + store.update([ + _Route(connection_id="new-a", host_id=host_id, address="a.com", port=9042), + _Route(connection_id="new-b", host_id=host_id, address="b.com", port=9042), + ]) + result = store.get_by_host_id(host_id) + self.assertEqual(result.connection_id, "new-a") + + +class TestClientRoutesHandler(unittest.TestCase): + + def setUp(self): + self.conn_id = uuid.uuid4() + self.proxy = ClientRouteProxy(str(self.conn_id), "10.0.0.1") + self.config = ClientRoutesConfig([self.proxy]) + + def test_handler_initialization(self): + handler = _ClientRoutesHandler(self.config, ssl_enabled=False) + self.assertIsNotNone(handler) + self.assertEqual(handler.ssl_enabled, False) + + @patch.object(_ClientRoutesHandler, '_query_all_routes_for_connections') + def test_initialize(self, mock_query): + host_id = uuid.uuid4() + mock_query.return_value = [ + _Route( + connection_id=self.conn_id, + host_id=host_id, + address="node1.example.com", + port=9042, + ) + ] + + handler = _ClientRoutesHandler(self.config) + mock_conn = Mock() + + handler.initialize(mock_conn, timeout=5.0) + + mock_query.assert_called_once() + route = handler._routes.get_by_host_id(host_id) + self.assertIsNotNone(route) + self.assertEqual(route.address, "node1.example.com") + + @patch.object(_ClientRoutesHandler, '_query_routes_for_change_event') + def test_handle_change_filters_by_configured_connection_ids(self, mock_query): + """Events with unrelated connection_ids should be ignored.""" + handler = _ClientRoutesHandler(self.config) + mock_conn = Mock() + host_id = str(uuid.uuid4()) + + # Event with a connection_id NOT in our config → should return early + handler.handle_client_routes_change( + mock_conn, 5.0, + ClientRoutesChangeType.UPDATE_NODES, + connection_ids=["unrelated-conn-id"], + host_ids=[host_id], + ) + mock_query.assert_not_called() + + @patch.object(_ClientRoutesHandler, '_query_routes_for_change_event') + def test_handle_change_merges_when_host_ids_present(self, mock_query): + """When host_ids are provided, routes should be merged (not full replace).""" + handler = _ClientRoutesHandler(self.config) + mock_conn = Mock() + + existing_host = uuid.uuid4() + new_host = uuid.uuid4() + conn_id = str(self.conn_id) + + # Pre-populate a route + handler._routes.update([ + _Route(connection_id=conn_id, host_id=existing_host, address="old.com", port=9042), + ]) + + mock_query.return_value = [ + _Route(connection_id=conn_id, host_id=new_host, address="new.com", port=9042), + ] + + handler.handle_client_routes_change( + mock_conn, 5.0, + ClientRoutesChangeType.UPDATE_NODES, + connection_ids=[conn_id], + host_ids=[str(new_host)], + ) + + # Existing route should still be there (merge, not replace) + self.assertIsNotNone(handler._routes.get_by_host_id(existing_host)) + self.assertIsNotNone(handler._routes.get_by_host_id(new_host)) + + @patch.object(_ClientRoutesHandler, '_query_all_routes_for_connections') + def test_handle_change_updates_when_no_host_ids(self, mock_query): + """When no host_ids are provided, routes should be fully replaced.""" + handler = _ClientRoutesHandler(self.config) + mock_conn = Mock() + conn_id = str(self.conn_id) + + old_host = uuid.uuid4() + handler._routes.update([ + _Route(connection_id=conn_id, host_id=old_host, address="old.com", port=9042), + ]) + + new_host = uuid.uuid4() + mock_query.return_value = [ + _Route(connection_id=conn_id, host_id=new_host, address="new.com", port=9042), + ] + + handler.handle_client_routes_change( + mock_conn, 5.0, + ClientRoutesChangeType.UPDATE_NODES, + connection_ids=None, + host_ids=None, + ) + + # Full replace: old_host gone, new_host present + self.assertIsNone(handler._routes.get_by_host_id(old_host)) + self.assertIsNotNone(handler._routes.get_by_host_id(new_host)) + + @patch.object(_ClientRoutesHandler, '_query_routes_for_change_event') + def test_handle_change_propagates_query_failure(self, mock_query): + """If _query_routes raises, handle_client_routes_change should propagate.""" + handler = _ClientRoutesHandler(self.config) + mock_conn = Mock() + mock_query.side_effect = Exception("network error") + + conn_id = self.proxy.connection_id + host_id = str(uuid.uuid4()) + with self.assertRaises(Exception) as cm: + handler.handle_client_routes_change( + mock_conn, 5.0, + ClientRoutesChangeType.UPDATE_NODES, + connection_ids=[conn_id], + host_ids=[host_id], + ) + self.assertIn("network error", str(cm.exception)) + + @patch.object(_ClientRoutesHandler, '_query_all_routes_for_connections') + def test_initialize_propagates_exception_on_failure(self, mock_query): + """initialize should propagate exceptions to caller.""" + handler = _ClientRoutesHandler(self.config) + mock_conn = Mock() + mock_query.side_effect = Exception("query failed") + + with self.assertRaises(Exception) as ctx: + handler.initialize(mock_conn, 5.0) + self.assertIn("query failed", str(ctx.exception)) + self.assertEqual(mock_query.call_count, 1) + + @patch.object(_ClientRoutesHandler, '_query_all_routes_for_connections') + def test_initialize_keeps_old_routes_on_failure(self, mock_query): + """On failure, existing routes must be preserved (critical for PL clusters).""" + handler = _ClientRoutesHandler(self.config) + mock_conn = Mock() + host_id = uuid.uuid4() + + # Pre-populate a route + handler._routes.update([ + _Route(connection_id=str(self.conn_id), host_id=host_id, address="old.com", port=9042), + ]) + + mock_query.side_effect = Exception("query failed") + with self.assertRaises(Exception): + handler.initialize(mock_conn, 5.0) + + # Old route must still be there + self.assertIsNotNone(handler._routes.get_by_host_id(host_id)) + + @patch.object(_ClientRoutesHandler, '_query_all_routes_for_connections') + def test_initialize_updates_routes_on_success(self, mock_query): + """initialize should update routes on success.""" + handler = _ClientRoutesHandler(self.config) + mock_conn = Mock() + host_id = uuid.uuid4() + + mock_query.return_value = [ + _Route(connection_id=str(self.conn_id), host_id=host_id, address="new.com", port=9042), + ] + + handler.initialize(mock_conn, 5.0) + + self.assertEqual(mock_query.call_count, 1) + route = handler._routes.get_by_host_id(host_id) + self.assertIsNotNone(route) + self.assertEqual(route.address, "new.com") + +class TestClientRoutesEndPoint(unittest.TestCase): + + def setUp(self): + self.conn_id = uuid.uuid4() + self.proxy = ClientRouteProxy(str(self.conn_id), "10.0.0.1") + self.config = ClientRoutesConfig([self.proxy]) + self.handler = _ClientRoutesHandler(self.config, ssl_enabled=False) + + def test_resolve_falls_back_when_no_mapping(self): + """resolve() should return original address/port when no route mapping exists.""" + host_id = uuid.uuid4() + ep = ClientRoutesEndPoint( + host_id=host_id, + handler=self.handler, + original_address="10.0.0.1", + original_port=9042, + ) + self.assertEqual(ep.resolve(), ("10.0.0.1", 9042)) + + @patch('cassandra.client_routes.socket.getaddrinfo', + return_value=[(socket.AF_INET, socket.SOCK_STREAM, 0, '', ("192.168.1.100", 9042))]) + def test_resolve_returns_address_when_route_exists(self, _mock_getaddrinfo): + """resolve() should return the DNS-resolved address and port when a route exists.""" + host_id = uuid.uuid4() + self.handler._routes.update([ + _Route(connection_id=str(self.conn_id), host_id=host_id, + address="nlb.example.com", port=9042), + ]) + ep = ClientRoutesEndPoint( + host_id=host_id, + handler=self.handler, + original_address="10.0.0.1", + original_port=9042, + ) + self.assertEqual(ep.resolve(), ("192.168.1.100", 9042)) + _mock_getaddrinfo.assert_called_once_with( + "nlb.example.com", 9042, socket.AF_UNSPEC, socket.SOCK_STREAM) + + @patch('cassandra.client_routes.socket.getaddrinfo', + side_effect=socket.gaierror("DNS resolution failed")) + def test_resolve_host_dns_failure_raises(self, _mock_getaddrinfo): + """resolve_host should propagate socket.gaierror on DNS failure.""" + host_id = uuid.uuid4() + self.handler._routes.update([ + _Route(connection_id=str(self.conn_id), host_id=host_id, + address="nonexistent.example.com", port=9042), + ]) + with self.assertRaises(socket.gaierror): + self.handler.resolve_host(host_id) + + def test_resolve_host_missing_port_raises(self): + """resolve_host should raise ValueError when route has no port.""" + host_id = uuid.uuid4() + self.handler._routes.update([ + _Route(connection_id=str(self.conn_id), host_id=host_id, + address="host.com", port=0), + ]) + with self.assertRaises(ValueError): + self.handler.resolve_host(host_id) + + +class TestClientRoutesEndPointFactory(unittest.TestCase): + + def setUp(self): + self.conn_id = uuid.uuid4() + proxy = ClientRouteProxy(str(self.conn_id), "10.0.0.1") + self.config = ClientRoutesConfig([proxy]) + self.handler = _ClientRoutesHandler(self.config, ssl_enabled=False) + self.factory = ClientRoutesEndPointFactory(self.handler, default_port=9042) + + def test_create_from_row(self): + """Factory should create a ClientRoutesEndPoint from a peers row.""" + host_id = uuid.uuid4() + row = { + "host_id": host_id, + "rpc_address": "10.0.0.5", + "native_transport_port": 9042, + "peer": "10.0.0.5", + } + ep = self.factory.create(row) + self.assertIsInstance(ep, ClientRoutesEndPoint) + self.assertEqual(ep.host_id, host_id) + self.assertEqual(ep.address, "10.0.0.5") + + def test_create_missing_host_id_raises(self): + """Factory should raise ValueError when row has no host_id.""" + row = {"rpc_address": "10.0.0.5", "native_transport_port": 9042} + with self.assertRaises(ValueError): + self.factory.create(row) + +class TestClientRoutesSSLValidation(unittest.TestCase): + + def test_check_hostname_with_ssl_context_raises(self): + """Cluster should reject check_hostname=True with client_routes_config.""" + ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + self.assertTrue(ssl_ctx.check_hostname) + + config = ClientRoutesConfig( + proxies=[ClientRouteProxy(str(uuid.uuid4()), "10.0.0.1")] + ) + with self.assertRaises(ValueError) as cm: + Cluster( + contact_points=["10.0.0.1"], + ssl_context=ssl_ctx, + client_routes_config=config, + ) + self.assertIn("check_hostname", str(cm.exception)) + + def test_check_hostname_with_ssl_options_raises(self): + """Cluster should reject check_hostname=True in ssl_options with client_routes_config.""" + config = ClientRoutesConfig( + proxies=[ClientRouteProxy(str(uuid.uuid4()), "10.0.0.1")] + ) + with self.assertRaises(ValueError) as cm: + Cluster( + contact_points=["10.0.0.1"], + ssl_options={'check_hostname': True}, + client_routes_config=config, + ) + self.assertIn("check_hostname", str(cm.exception)) + + def test_disabled_check_hostname_with_client_routes_ok(self): + """Cluster should allow check_hostname=False with client_routes_config.""" + ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ssl_ctx.check_hostname = False + + config = ClientRoutesConfig( + proxies=[ClientRouteProxy(str(uuid.uuid4()), "10.0.0.1")] + ) + # Should not raise + cluster = Cluster( + contact_points=["10.0.0.1"], + ssl_context=ssl_ctx, + client_routes_config=config, + ) + cluster.shutdown() + + def test_no_ssl_with_client_routes_ok(self): + """Cluster should allow client_routes_config without SSL.""" + config = ClientRoutesConfig( + proxies=[ClientRouteProxy(str(uuid.uuid4()), "10.0.0.1")] + ) + # Should not raise + cluster = Cluster( + contact_points=["10.0.0.1"], + client_routes_config=config, + ) + cluster.shutdown() + + +if __name__ == '__main__': + unittest.main() From 6743edd4baf8116195cf54e422cd3c91e6390d3b Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Wed, 11 Mar 2026 11:16:03 +0100 Subject: [PATCH 008/200] tests: add integration tests for client routes Add comprehensive integration tests covering: - TCP proxy and NLB emulator infrastructure for simulating private link connectivity - query_routes filtering with different connection/host ID combinations - Full private-link connectivity verifying all driver connections go exclusively through the NLB proxy - Dynamic route updates via REST API with driver reconnection through new proxy ports --- .../standard/test_client_routes.py | 1314 +++++++++++++++++ 1 file changed, 1314 insertions(+) create mode 100644 tests/integration/standard/test_client_routes.py diff --git a/tests/integration/standard/test_client_routes.py b/tests/integration/standard/test_client_routes.py new file mode 100644 index 0000000000..a8a3c30f2c --- /dev/null +++ b/tests/integration/standard/test_client_routes.py @@ -0,0 +1,1314 @@ +# Copyright 2026 ScyllaDB, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Comprehensive integration tests for Client Routes (Private Link) support. + +Includes: +- TCP proxy and NLB emulator for simulating private link infrastructure +- Tests verifying all connections go exclusively through the proxy +- Tests for dynamic route updates and topology changes +- Tests for query_routes filtering +""" + +import logging +import os +import select +import shutil +import socket +import ssl +import subprocess +import tempfile +import threading +import time +import unittest +import uuid + +import json as _json +import urllib.request + +from cassandra.cluster import Cluster +from cassandra.client_routes import ClientRoutesConfig, ClientRouteProxy +from cassandra.connection import ClientRoutesEndPoint +from cassandra.policies import RoundRobinPolicy +from tests.integration import ( + TestCluster, + get_cluster, + get_node, + use_cluster, + wait_for_node_socket, + skip_scylla_version_lt, +) +from tests.util import wait_until_not_raised + +log = logging.getLogger(__name__) + +class TcpProxy: + """ + A simple TCP proxy that forwards connections from a local listen port + to a target (host, port). Tracks active connections so tests can + verify that traffic flows through the proxy. + """ + + BUF_SIZE = 65536 + + def __init__(self, listen_host, listen_port, target_host, target_port): + self.listen_host = listen_host + self.listen_port = listen_port + self.target_host = target_host + self.target_port = target_port + + self._server_sock = None + self._running = False + self._thread = None + self._lock = threading.Lock() + self._connections = set() + self.total_connections = 0 + + def start(self): + self._server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self._server_sock.bind((self.listen_host, self.listen_port)) + self.listen_port = self._server_sock.getsockname()[1] + self._server_sock.listen(128) + self._server_sock.setblocking(False) + self._running = True + self._thread = threading.Thread(target=self._run, daemon=True, + name="proxy-%s:%d" % (self.listen_host, self.listen_port)) + self._thread.start() + log.info("TcpProxy started %s:%d -> %s:%d", + self.listen_host, self.listen_port, + self.target_host, self.target_port) + + def stop(self): + self._running = False + if self._server_sock: + try: + self._server_sock.close() + except Exception: + pass + with self._lock: + for csock, tsock in list(self._connections): + self._close_pair(csock, tsock) + self._connections.clear() + if self._thread: + self._thread.join(timeout=5) + log.info("TcpProxy stopped %s:%d", self.listen_host, self.listen_port) + + @property + def active_connections(self): + with self._lock: + return len(self._connections) + + def retarget(self, new_host, new_port): + """Change the backend target for new connections (existing ones keep the old target).""" + self.target_host = new_host + self.target_port = new_port + log.info("TcpProxy %s:%d retargeted to %s:%d", + self.listen_host, self.listen_port, new_host, new_port) + + def drop_connections(self): + """Forcibly close all active connections.""" + with self._lock: + for csock, tsock in list(self._connections): + self._close_pair(csock, tsock) + self._connections.clear() + log.info("TcpProxy %s:%d dropped all connections", self.listen_host, self.listen_port) + + def _run(self): + while self._running: + try: + readable, _, _ = select.select([self._server_sock], [], [], 0.2) + except (ValueError, OSError): + break + for sock in readable: + if sock is self._server_sock: + try: + client_sock, _ = self._server_sock.accept() + except OSError: + continue + self._handle_new_connection(client_sock) + + def _handle_new_connection(self, client_sock, target_host=None, target_port=None): + target_host = target_host or self.target_host + target_port = target_port or self.target_port + try: + target_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + target_sock.connect((target_host, target_port)) + except Exception as e: + log.warning("TcpProxy %s:%d failed to connect to target %s:%d: %s", + self.listen_host, self.listen_port, + target_host, target_port, e) + client_sock.close() + return + + with self._lock: + self._connections.add((client_sock, target_sock)) + self.total_connections += 1 + + t = threading.Thread(target=self._forward_loop, + args=(client_sock, target_sock), + daemon=True) + t.start() + + def _forward_loop(self, client_sock, target_sock): + try: + while self._running: + readable, _, _ = select.select([client_sock, target_sock], [], [], 0.5) + for sock in readable: + data = sock.recv(self.BUF_SIZE) + if not data: + return + if sock is client_sock: + target_sock.sendall(data) + else: + client_sock.sendall(data) + except (OSError, ConnectionResetError, BrokenPipeError): + pass + finally: + with self._lock: + self._connections.discard((client_sock, target_sock)) + self._close_pair(client_sock, target_sock) + + @staticmethod + def _close_pair(csock, tsock): + for s in (csock, tsock): + try: + s.close() + except Exception: + pass + + +class NLBEmulator: + """ + Emulates a Network Load Balancer for a CCM cluster. + + Provides: + - One *discovery port* (round-robin across all live nodes, used as the + driver's ``contact_points``). + - One *per-node port* for each node (dedicated proxy to that node's + native transport port). + + All proxies listen on ``LISTEN_HOST`` (127.254.254.101), an address + outside the CCM node range, simulating a real NLB endpoint. + + Port layout (all ports are OS-assigned by default): + LISTEN_HOST:discovery_port -> round-robin to all live nodes + LISTEN_HOST: -> node1 (127.0.0.1:9042) + LISTEN_HOST: -> node2 (127.0.0.2:9042) + ... + + Automatically creates/removes per-node proxies when nodes are + added/removed so CCM cluster operations are reflected seamlessly. + """ + + LISTEN_HOST = "127.254.254.101" + + def __init__(self, discovery_port=0, + per_node_base=0, + native_port=9042, + node_addresses=None): + self.discovery_port = discovery_port + self.per_node_base = per_node_base + self.native_port = native_port + self._deferred_node_addresses = node_addresses + + self._node_proxies = {} + self._discovery_proxy = None + self._rr_index = 0 + self._lock = threading.Lock() + self._running = False + + def start(self, node_addresses): + """ + Start the NLB with an initial set of node addresses. + + :param node_addresses: dict of node_id -> ip_address, e.g. + {1: "127.0.0.1", 2: "127.0.0.2"} + """ + self._running = True + try: + for node_id, addr in node_addresses.items(): + self._add_node_proxy(node_id, addr) + + first_addr = list(node_addresses.values())[0] + self._discovery_proxy = TcpProxy( + self.LISTEN_HOST, self.discovery_port, + first_addr, self.native_port, + ) + self._discovery_proxy.start() + self.discovery_port = self._discovery_proxy.listen_port + except Exception: + self.stop() + raise + original_handler = self._discovery_proxy._handle_new_connection + + def rr_handler(client_sock): + addrs = self._live_addresses() + if not addrs: + client_sock.close() + return + idx = self._rr_index % len(addrs) + self._rr_index += 1 + addr = addrs[idx] + original_handler(client_sock, target_host=addr, target_port=self.native_port) + + self._discovery_proxy._handle_new_connection = rr_handler + + log.info("NLB started: discovery=%s:%d, %d node proxies", + self.LISTEN_HOST, self.discovery_port, len(self._node_proxies)) + return self + + def __enter__(self): + if not self._running and self._deferred_node_addresses is not None: + self.start(self._deferred_node_addresses) + return self + + def __exit__(self, *args): + self.stop() + + def stop(self): + self._running = False + if self._discovery_proxy: + self._discovery_proxy.stop() + for proxy in self._node_proxies.values(): + proxy.stop() + self._node_proxies.clear() + log.info("NLB stopped") + + def add_node(self, node_id, addr): + self._add_node_proxy(node_id, addr) + + def remove_node(self, node_id): + with self._lock: + proxy = self._node_proxies.pop(node_id, None) + if proxy: + proxy.stop() + log.info("NLB removed node %d", node_id) + + def node_port(self, node_id): + proxy = self._node_proxies.get(node_id) + if proxy: + return proxy.listen_port + return self.per_node_base + node_id + + def get_node_proxy(self, node_id): + return self._node_proxies.get(node_id) + + def total_proxy_connections(self): + return sum(p.total_connections for p in self._node_proxies.values()) + + def active_proxy_connections(self): + return sum(p.active_connections for p in self._node_proxies.values()) + + def drop_all_connections(self): + for proxy in self._node_proxies.values(): + proxy.drop_connections() + if self._discovery_proxy: + self._discovery_proxy.drop_connections() + + def _add_node_proxy(self, node_id, addr): + port = 0 + proxy = TcpProxy(self.LISTEN_HOST, port, addr, self.native_port) + proxy.start() + with self._lock: + self._node_proxies[node_id] = proxy + log.info("NLB added node %d: %s:%d -> %s:%d", + node_id, self.LISTEN_HOST, port, addr, self.native_port) + + def _live_addresses(self): + """IPs of nodes with active proxies.""" + return [p.target_host for p in self._node_proxies.values()] + +def post_client_routes(contact_point, routes): + """ + Post client routes to Scylla's REST API. + + :param contact_point: IP/hostname of a Scylla node (e.g. "127.0.0.1") + :param routes: List of route dicts with keys: connection_id, host_id, address, port + and optionally tls_port + """ + payload = [] + for route in routes: + entry = { + "connection_id": str(route["connection_id"]), + "host_id": str(route["host_id"]), + "address": route["address"], + "port": route["port"], + } + if route.get("tls_port") is not None: + entry["tls_port"] = route["tls_port"] + payload.append(entry) + + url = "http://%s:10000/v2/client-routes" % contact_point + log.info("Posting %d routes to %s", len(payload), url) + data = _json.dumps(payload).encode("utf-8") + req = urllib.request.Request( + url, + data=data, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + method="POST", + ) + response = urllib.request.urlopen(req) + log.info("Routes posted successfully (status %d)", response.status) + + +def get_host_ids_from_cluster(session): + """ + Build a mapping of rpc_address -> host_id for all nodes in the cluster. + + Uses the driver's metadata rather than querying system.local / system.peers + directly, because those queries can be routed to different coordinators + (system.local returns the coordinator's own info while system.peers omits + the coordinator), leading to a node being missing from the map. + """ + host_id_map = {} + for host in session.cluster.metadata.all_hosts(): + host_id_map[host.address] = host.host_id + return host_id_map + + +def build_routes_for_nlb(connection_id, host_id_map, nlb): + """ + Build routes that direct each host_id through the NLB per-node proxy. + + :param connection_id: Connection ID string + :param host_id_map: dict ip -> uuid host_id (from get_host_ids_from_cluster) + :param nlb: NLBEmulator instance + :return: list of route dicts + """ + routes = [] + for ip, host_id in host_id_map.items(): + node_id = int(ip.split(".")[-1]) + port = nlb.node_port(node_id) + routes.append({ + "connection_id": connection_id, + "host_id": host_id, + "address": NLBEmulator.LISTEN_HOST, + "port": port, + }) + return routes + + +def post_routes_for_nlb(contact_point, connection_id, host_id_map, nlb): + """Build routes for the NLB and POST them via the REST API.""" + routes = build_routes_for_nlb(connection_id, host_id_map, nlb) + post_client_routes(contact_point, routes) + return routes + +def wait_for_routes_visible(session, connection_id, expected_count, timeout=10, poll_interval=0.1): + """ + Poll system.client_routes on **every** node until each one sees at + least *expected_count* rows for *connection_id*. + + ``system.client_routes`` is a node-local table, so routes posted via + the REST API to one node are not guaranteed to be visible on the + others at the same time. This helper ensures they have propagated + everywhere before the test proceeds. + + :param session: an active driver Session (direct, not through NLB) + :param connection_id: the connection_id string to filter on + :param expected_count: how many rows we expect to see per node + :param timeout: maximum seconds to wait + :param poll_interval: seconds between polls + """ + all_hosts = list(session.cluster.metadata.all_hosts()) + deadline = time.time() + timeout + while True: + pending_hosts = [] + for host in all_hosts: + rows = list(session.execute( + "SELECT * FROM system.client_routes WHERE connection_id = %s", + (connection_id,), + host=host, + )) + if len(rows) < expected_count: + pending_hosts.append((host, len(rows))) + if not pending_hosts: + return + if time.time() >= deadline: + details = ", ".join( + "%s: %d" % (h.address, count) for h, count in pending_hosts + ) + raise RuntimeError( + "Timed out waiting for %d routes (connection_id=%s) to appear " + "in system.client_routes on all nodes; pending: %s" + % (expected_count, connection_id, details) + ) + time.sleep(poll_interval) + + +def node_id_from_ip(ip): + """Extract node_id from an IP like '127.0.0.3' -> 3.""" + return int(ip.split(".")[-1]) + + +def assert_routes_via_nlb(test, cluster, nlb, expected_node_ids): + """ + Assert that every host in *expected_node_ids* has its endpoint + resolving through the NLB (correct address and per-node port). + """ + nlb_listen_host = NLBEmulator.LISTEN_HOST + expected_node_ids = set(expected_node_ids) + + seen_node_ids = set() + for host in cluster.metadata.all_hosts(): + ep = host.endpoint + if not isinstance(ep, ClientRoutesEndPoint): + continue + node_id = node_id_from_ip(ep.address) + if node_id not in expected_node_ids: + continue + resolved_addr, resolved_port = ep.resolve() + test.assertEqual( + resolved_addr, nlb_listen_host, + "Node %d endpoint should resolve to NLB address %s, got %s" + % (node_id, nlb_listen_host, resolved_addr), + ) + test.assertEqual( + resolved_port, nlb.node_port(node_id), + "Node %d endpoint should resolve to NLB port %d, got %d" + % (node_id, nlb.node_port(node_id), resolved_port), + ) + seen_node_ids.add(node_id) + test.assertEqual( + seen_node_ids, expected_node_ids, + "Not all expected nodes found in metadata endpoints", + ) + + +def assert_routes_direct(test, cluster, expected_node_ids, direct_port=9042): + """ + Assert that every host in *expected_node_ids* has its endpoint + resolving to the node's own IP on *direct_port*. + """ + expected_node_ids = set(expected_node_ids) + + for host in cluster.metadata.all_hosts(): + ep = host.endpoint + if not isinstance(ep, ClientRoutesEndPoint): + continue + node_id = node_id_from_ip(ep.address) + if node_id not in expected_node_ids: + continue + resolved_addr, resolved_port = ep.resolve() + expected_ip = "127.0.0.%d" % node_id + test.assertEqual( + resolved_addr, expected_ip, + "Node %d endpoint should resolve to direct address %s, got %s" + % (node_id, expected_ip, resolved_addr), + ) + test.assertEqual( + resolved_port, direct_port, + "Node %d endpoint should resolve to direct port %d, got %d" + % (node_id, direct_port, resolved_port), + ) + + +def setup_module(): + os.environ['SCYLLA_EXT_OPTS'] = "--smp 2 --memory 2048M" + use_cluster('test_client_routes', [3], start=True) + +@skip_scylla_version_lt(reason='scylladb/scylladb#26992 - system.client_routes is not yet supported', + scylla_version="2026.1.0") +class TestGetHostPortMapping(unittest.TestCase): + """ + Test _query_all_routes_for_connections and _query_routes_for_change_event + methods with different filtering scenarios. + """ + + @classmethod + def setUpClass(cls): + cls.cluster = TestCluster(client_routes_config=ClientRoutesConfig( + proxies=[ClientRouteProxy("conn_id", "127.0.0.1")])) + cls.session = cls.cluster.connect() + + cls.host_ids = [uuid.uuid4() for _ in range(3)] + cls.connection_ids = [str(uuid.uuid4()) for _ in range(3)] + cls.expected = [] + + for idx, host_id in enumerate(cls.host_ids): + ip = f"127.0.0.{idx + 1}" + for connection_id in cls.connection_ids: + cls.expected.append({ + 'connection_id': connection_id, + 'host_id': host_id, + 'address': ip, + 'port': 9042, + 'tls_port': 9142, + }) + + cls._sort_routes(cls.expected) + post_client_routes(cls.cluster.contact_points[0], cls.expected) + + @classmethod + def tearDownClass(cls): + cls.cluster.shutdown() + + @staticmethod + def _sort_routes(routes): + routes.sort(key=lambda r: (str(r['connection_id']), str(r['host_id']))) + + def _routes_to_dicts(self, routes): + """Convert _Route objects to comparable dicts, adjusting port for ssl_enabled.""" + return [ + { + 'connection_id': route.connection_id, + 'host_id': route.host_id, + 'address': route.address, + 'port': route.port, + } + for route in routes + ] + + def _expected_dicts(self, expected): + """Build expected dicts with tls_port or port based on ssl_enabled.""" + port_key = 'tls_port' if self.cluster._client_routes_handler.ssl_enabled else 'port' + return [ + { + 'connection_id': e['connection_id'], + 'host_id': e['host_id'], + 'address': e['address'], + 'port': e[port_key], + } + for e in expected + ] + + def test_get_all_routes_for_all_connections(self): + """Querying all connection IDs returns every route.""" + cc = self.cluster.control_connection + routes = self.cluster._client_routes_handler._query_all_routes_for_connections( + cc._connection, cc._timeout, self.connection_ids, + ) + got = self._routes_to_dicts(routes) + self._sort_routes(got) + expected = self._expected_dicts(self.expected) + self._sort_routes(expected) + self.assertEqual(got, expected) + + def test_get_routes_for_single_connection(self): + """Querying a single connection ID returns only its routes.""" + cc = self.cluster.control_connection + routes = self.cluster._client_routes_handler._query_all_routes_for_connections( + cc._connection, cc._timeout, [self.connection_ids[0]], + ) + got = self._routes_to_dicts(routes) + self._sort_routes(got) + filtered = [r for r in self.expected + if r['connection_id'] == self.connection_ids[0]] + expected = self._expected_dicts(filtered) + self._sort_routes(expected) + self.assertEqual(got, expected) + + def test_get_routes_for_change_event_all_pairs(self): + """Querying all (connection_id, host_id) pairs returns every route.""" + cc = self.cluster.control_connection + pairs = [(r['connection_id'], r['host_id']) for r in self.expected] + routes = self.cluster._client_routes_handler._query_routes_for_change_event( + cc._connection, cc._timeout, pairs, + ) + got = self._routes_to_dicts(routes) + self._sort_routes(got) + expected = self._expected_dicts(self.expected) + self._sort_routes(expected) + self.assertEqual(got, expected) + + def test_get_routes_for_change_event_single_pair(self): + """Querying a single (connection_id, host_id) pair returns one route.""" + cc = self.cluster.control_connection + target_conn_id = self.connection_ids[0] + target_host_id = self.host_ids[0] + routes = self.cluster._client_routes_handler._query_routes_for_change_event( + cc._connection, cc._timeout, [(target_conn_id, target_host_id)], + ) + got = self._routes_to_dicts(routes) + self._sort_routes(got) + filtered = [r for r in self.expected + if r['connection_id'] == target_conn_id + and r['host_id'] == target_host_id] + expected = self._expected_dicts(filtered) + self._sort_routes(expected) + self.assertEqual(got, expected) + +@skip_scylla_version_lt(reason='scylladb/scylladb#26992 - system.client_routes is not yet supported', + scylla_version="2026.1.0") +class TestPrivateLinkConnectivity(unittest.TestCase): + """ + Verifies the driver connects to all cluster nodes exclusively through + the NLB proxy, never directly. + + Setup: + 1. Start a 3-node CCM cluster (done by setup_module). + 2. Start an NLB emulator with per-node proxies. + 3. Use a direct session to read host_ids, then POST client routes + pointing each host_id at the NLB proxy port. + 4. Create a client-routes-enabled session using the NLB discovery + port as the contact point. + 5. Verify all driver connections go through proxy ports. + """ + + @classmethod + def setUpClass(cls): + cls.direct_cluster = TestCluster() + cls.direct_session = cls.direct_cluster.connect() + cls.host_id_map = get_host_ids_from_cluster(cls.direct_session) + log.info("Host ID map: %s", cls.host_id_map) + + cls.node_addrs = {} + for ip in cls.host_id_map: + node_id = int(ip.split(".")[-1]) + cls.node_addrs[node_id] = ip + + cls.nlb = NLBEmulator() + cls.nlb.start(cls.node_addrs) + + cls.connection_id = str(uuid.uuid4()) + post_routes_for_nlb("127.0.0.1", cls.connection_id, cls.host_id_map, cls.nlb) + wait_for_routes_visible(cls.direct_session, cls.connection_id, len(cls.host_id_map)) + + @classmethod + def tearDownClass(cls): + cls.direct_cluster.shutdown() + cls.nlb.stop() + + def _make_client_routes_cluster(self, **extra_kwargs): + """Create a Cluster configured with client-routes pointing at the NLB.""" + return Cluster( + contact_points=[NLBEmulator.LISTEN_HOST], + port=self.nlb.discovery_port, + client_routes_config=ClientRoutesConfig( + proxies=[ClientRouteProxy(self.connection_id, NLBEmulator.LISTEN_HOST)], + ), + load_balancing_policy=RoundRobinPolicy(), + **extra_kwargs, + ) + + def test_all_connections_through_proxy(self): + """Every pool connection must go through the NLB proxy, not directly.""" + with self._make_client_routes_cluster() as cluster: + session = cluster.connect(wait_for_all_pools=True) + + for _ in range(50): + session.execute("SELECT key FROM system.local") + + pool_state = session.get_pool_state() + self.assertEqual(len(pool_state), len(self.node_addrs), + "Driver should have pools for all nodes") + + for host, state in pool_state.items(): + node_id = node_id_from_ip(host.address) + proxy = self.nlb.get_node_proxy(node_id) + self.assertIsNotNone(proxy, f"No proxy for node {node_id}") + open_count = state['open_count'] + self.assertGreaterEqual( + proxy.total_connections, open_count, + f"Node {node_id} proxy saw {proxy.total_connections} " + f"connections but pool has {open_count} open — " + f"some connections bypassed the proxy") + + assert_routes_via_nlb(self, cluster, self.nlb, + self.node_addrs.keys()) + + def test_queries_succeed_through_proxy(self): + """Queries should work normally through the proxy.""" + with self._make_client_routes_cluster() as cluster: + session = cluster.connect() + session.execute( + "CREATE KEYSPACE IF NOT EXISTS test_cr_ks " + "WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 3}" + ) + session.execute( + "CREATE TABLE IF NOT EXISTS test_cr_ks.t (k int PRIMARY KEY, v text)" + ) + session.execute("INSERT INTO test_cr_ks.t (k, v) VALUES (1, 'hello')") + row = session.execute("SELECT v FROM test_cr_ks.t WHERE k = 1").one() + self.assertEqual(row.v, "hello") + + assert_routes_via_nlb(self, cluster, self.nlb, + self.node_addrs.keys()) + + def test_connection_recovery_after_proxy_drop(self): + """ + After the proxy drops all connections, the driver should reconnect + (still through the proxy). + """ + with self._make_client_routes_cluster() as cluster: + session = cluster.connect(wait_for_all_pools=True) + session.execute("SELECT key FROM system.local") + + assert_routes_via_nlb(self, cluster, self.nlb, + self.node_addrs.keys()) + + self.nlb.drop_all_connections() + + def query_ok(): + session.execute("SELECT key FROM system.local") + + wait_until_not_raised(query_ok, 1, 30) + + assert_routes_via_nlb(self, cluster, self.nlb, + self.node_addrs.keys()) + +@skip_scylla_version_lt(reason='scylladb/scylladb#26992 - system.client_routes is not yet supported', + scylla_version="2026.1.0") +class TestDynamicRouteUpdates(unittest.TestCase): + """ + Verify that when routes are updated (e.g. port changes), the driver + picks up the new routes and reconnects through the new proxy ports + after existing connections are dropped. + """ + + @classmethod + def setUpClass(cls): + cls.direct_cluster = TestCluster() + cls.direct_session = cls.direct_cluster.connect() + cls.host_id_map = get_host_ids_from_cluster(cls.direct_session) + + cls.node_addrs = {} + for ip in cls.host_id_map: + node_id = int(ip.split(".")[-1]) + cls.node_addrs[node_id] = ip + + cls.connection_id = str(uuid.uuid4()) + + @classmethod + def tearDownClass(cls): + cls.direct_cluster.shutdown() + + def test_route_update_causes_reconnect_to_new_port(self): + """ + 1. Start NLB v1, post routes -> driver connects through v1 ports. + 2. Start NLB v2 on different ports, post new routes. + 3. Drop v1 connections. + 4. Driver should reconnect through v2 ports. + """ + with NLBEmulator( + node_addresses=self.node_addrs, + ) as nlb_v1, NLBEmulator( + node_addresses=self.node_addrs, + ) as nlb_v2: + post_routes_for_nlb("127.0.0.1", self.connection_id, + self.host_id_map, nlb_v1) + wait_for_routes_visible(self.direct_session, self.connection_id, len(self.host_id_map)) + + with Cluster( + contact_points=[NLBEmulator.LISTEN_HOST], + port=nlb_v1.discovery_port, + client_routes_config=ClientRoutesConfig( + proxies=[ClientRouteProxy(self.connection_id, NLBEmulator.LISTEN_HOST)], + ), + load_balancing_policy=RoundRobinPolicy(), + ) as cluster: + session = cluster.connect(wait_for_all_pools=True) + session.execute("SELECT key FROM system.local") + + for node_id in self.node_addrs: + self.assertGreater( + nlb_v1.get_node_proxy(node_id).total_connections, 0) + assert_routes_via_nlb(self, cluster, nlb_v1, + self.node_addrs.keys()) + + post_routes_for_nlb("127.0.0.1", self.connection_id, + self.host_id_map, nlb_v2) + time.sleep(2) # let CLIENT_ROUTES_CHANGE propagate + + # Stop v1 per-node proxies entirely so v1 ports become + # unreachable, forcing the driver to reconnect through v2. + # (Merely dropping connections is insufficient because v1 + # proxies would still accept new connections before the + # route update propagates.) + for node_id in list(self.node_addrs.keys()): + nlb_v1.remove_node(node_id) + + def all_nodes_via_v2(): + session.execute("SELECT key FROM system.local") + for nid in self.node_addrs: + assert nlb_v2.get_node_proxy(nid).total_connections > 0, \ + "NLB v2 node %d proxy has no connections yet" % nid + + wait_until_not_raised(all_nodes_via_v2, 1, 30) + + assert_routes_via_nlb(self, cluster, nlb_v2, + self.node_addrs.keys()) + + +def _generate_ssl_certs(cert_dir, node_ips): + """ + Generate test SSL certificates with SANs covering the given node IPs. + + File names follow CCM's ``ScyllaCluster.enable_ssl()`` convention so the + resulting directory can be passed directly to ``enable_ssl(cert_dir, ...)``. + + Creates: + - ca.key / ca.crt: self-signed CA + - ccm_node.key / ccm_node.pem: server cert signed by CA with SANs for all node_ips + + :param cert_dir: directory to write files into (must exist) + :param node_ips: list of IP strings to include as SANs (e.g. ["127.0.0.1", "127.0.0.2"]) + """ + if shutil.which("openssl") is None: + raise unittest.SkipTest("openssl not found on PATH; skipping SSL cert generation") + + san_cnf = os.path.join(cert_dir, "san.cnf") + san_value = ",".join("IP:%s" % ip for ip in node_ips) + with open(san_cnf, "w") as f: + f.write("subjectAltName=%s\n" % san_value) + + def _run(cmd): + result = subprocess.run(cmd, cwd=cert_dir, capture_output=True, text=True) + if result.returncode != 0: + raise RuntimeError("Command failed: %s\n%s" % (" ".join(cmd), result.stderr)) + + _run(["openssl", "req", "-x509", "-newkey", "rsa:2048", + "-keyout", "ca.key", "-out", "ca.crt", + "-days", "1", "-nodes", "-subj", "/CN=Test CA"]) + + _run(["openssl", "req", "-newkey", "rsa:2048", + "-keyout", "ccm_node.key", "-out", "ccm_node.csr", + "-nodes", "-subj", "/CN=Test Server"]) + + _run(["openssl", "x509", "-req", + "-in", "ccm_node.csr", "-CA", "ca.crt", "-CAkey", "ca.key", + "-CAcreateserial", "-out", "ccm_node.pem", + "-days", "1", "-extfile", "san.cnf"]) + + log.info("Generated SSL certs in %s with SANs: %s", cert_dir, san_value) + + +@skip_scylla_version_lt(reason='scylladb/scylladb#26992 - system.client_routes is not yet supported', + scylla_version="2026.1.0") +class TestMixedDirectAndNlbConnections(unittest.TestCase): + """ + Verify the cluster works when some nodes are accessed through the NLB + proxy and others are accessed directly (no route posted, falls back + to the default endpoint). + """ + + @classmethod + def setUpClass(cls): + cls.direct_cluster = TestCluster() + cls.direct_session = cls.direct_cluster.connect() + cls.host_id_map = get_host_ids_from_cluster(cls.direct_session) + + cls.node_addrs = {} + for ip in cls.host_id_map: + node_id = int(ip.split(".")[-1]) + cls.node_addrs[node_id] = ip + + cls.connection_id = str(uuid.uuid4()) + + @classmethod + def tearDownClass(cls): + cls.direct_cluster.shutdown() + + def test_mixed_direct_and_nlb_connections(self): + """ + Post routes for only a subset of nodes (through NLB proxy). + Remaining nodes have no route and fall back to direct connections. + Queries should work through both paths. + """ + proxied_node_id = min(self.node_addrs.keys()) + proxied_ip = self.node_addrs[proxied_node_id] + + with NLBEmulator( + node_addresses={proxied_node_id: proxied_ip}, + ) as nlb: + proxied_host_id = self.host_id_map[proxied_ip] + routes = [{ + "connection_id": self.connection_id, + "host_id": proxied_host_id, + "address": NLBEmulator.LISTEN_HOST, + "port": nlb.node_port(proxied_node_id), + }] + post_client_routes("127.0.0.1", routes) + time.sleep(1) + + with Cluster( + contact_points=["127.0.0.1"], + client_routes_config=ClientRoutesConfig( + proxies=[ClientRouteProxy(self.connection_id, NLBEmulator.LISTEN_HOST)], + ), + load_balancing_policy=RoundRobinPolicy(), + ) as cluster: + session = cluster.connect(wait_for_all_pools=True) + + for _ in range(50): + session.execute("SELECT key FROM system.local") + + assert_routes_via_nlb(self, cluster, nlb, + [proxied_node_id]) + + direct_node_ids = set(self.node_addrs.keys()) - {proxied_node_id} + assert_routes_direct(self, cluster, direct_node_ids) + + proxy = nlb.get_node_proxy(proxied_node_id) + self.assertGreater(proxy.total_connections, 0, + "Proxied node should have connections through NLB") + + +@skip_scylla_version_lt(reason='scylladb/scylladb#26992 - system.client_routes is not yet supported', + scylla_version="2026.1.0") +class TestSslThroughNlb(unittest.TestCase): + """ + Verify SSL with check_hostname=False works through the NLB proxy. + + When using client routes, connections go through NLB proxies whose + addresses won't match server certificates, so hostname verification + must be disabled. Certificate chain validation (verify_mode=CERT_REQUIRED) + is still active — only hostname matching is skipped. + + The driver raises ValueError at Cluster init time if check_hostname=True + is used with client_routes_config. + """ + + @classmethod + def setUpClass(cls): + cls.direct_cluster = TestCluster() + cls.direct_session = cls.direct_cluster.connect() + cls.host_id_map = get_host_ids_from_cluster(cls.direct_session) + cls.direct_cluster.shutdown() + + cls.node_addrs = {} + for ip in cls.host_id_map: + node_id = int(ip.split(".")[-1]) + cls.node_addrs[node_id] = ip + + cls.connection_id = str(uuid.uuid4()) + + cls.cert_dir = tempfile.mkdtemp(prefix="client-routes-ssl-") + cert_ips = list(cls.node_addrs.values()) + _generate_ssl_certs(cls.cert_dir, cert_ips) + + cls.ccm_cluster = get_cluster() + cls.ccm_cluster.stop() + cls.ccm_cluster.set_configuration_options({ + 'client_encryption_options': { + 'enabled': True, + 'certificate': os.path.join(cls.cert_dir, "ccm_node.pem"), + 'keyfile': os.path.join(cls.cert_dir, "ccm_node.key"), + } + }) + cls.ccm_cluster.start(wait_for_binary_proto=True) + + @classmethod + def tearDownClass(cls): + cls.ccm_cluster.stop() + cls.ccm_cluster.set_configuration_options({ + 'client_encryption_options': { + 'enabled': False, + } + }) + cls.ccm_cluster.start(wait_for_binary_proto=True) + + shutil.rmtree(cls.cert_dir, ignore_errors=True) + + def test_ssl_without_hostname_verification_through_nlb(self): + """ + Connect through NLB with SSL but check_hostname=False. + + When using client routes, connections go through NLB proxies + whose addresses won't match server certificates, so hostname + verification must be disabled. Certificate chain validation + (verify_mode=CERT_REQUIRED) is still active. + """ + with NLBEmulator( + node_addresses=self.node_addrs, + ) as nlb: + routes = build_routes_for_nlb( + self.connection_id, self.host_id_map, nlb, + ) + for route in routes: + route["tls_port"] = route["port"] + post_client_routes("127.0.0.1", routes) + + ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ssl_ctx.check_hostname = False + ssl_ctx.load_verify_locations(os.path.join(self.cert_dir, 'ca.crt')) + + self.assertFalse(ssl_ctx.check_hostname, + "check_hostname must be False for this test") + self.assertEqual(ssl_ctx.verify_mode, ssl.CERT_REQUIRED, + "verify_mode must be CERT_REQUIRED") + + def routes_visible(): + with TestCluster( + contact_points=["127.0.0.1"], + ssl_context=ssl_ctx, + ) as c: + session = c.connect() + rs = session.execute( + "SELECT * FROM system.client_routes " + "WHERE connection_id = %s ALLOW FILTERING", + (self.connection_id,) + ) + return len(list(rs)) >= len(self.host_id_map) + + wait_until_not_raised( + lambda: self.assertTrue(routes_visible()), + 0.5, 10, + ) + + with Cluster( + contact_points=[NLBEmulator.LISTEN_HOST], + port=nlb.discovery_port, + ssl_context=ssl_ctx, + client_routes_config=ClientRoutesConfig( + proxies=[ClientRouteProxy(self.connection_id, NLBEmulator.LISTEN_HOST)], + ), + load_balancing_policy=RoundRobinPolicy(), + ) as cluster: + session = cluster.connect(wait_for_all_pools=True) + + for _ in range(20): + row = session.execute( + "SELECT release_version FROM system.local" + ).one() + self.assertIsNotNone(row) + + assert_routes_via_nlb(self, cluster, nlb, + self.node_addrs.keys()) + + def test_ssl_with_hostname_verification_raises_error(self): + """ + Verify that Cluster raises ValueError when client_routes_config + is used with SSL hostname verification enabled. + """ + ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ssl_ctx.load_verify_locations(os.path.join(self.cert_dir, 'ca.crt')) + self.assertTrue(ssl_ctx.check_hostname) + + with self.assertRaises(ValueError) as cm: + Cluster( + contact_points=[NLBEmulator.LISTEN_HOST], + ssl_context=ssl_ctx, + client_routes_config=ClientRoutesConfig( + proxies=[ClientRouteProxy("test-id", NLBEmulator.LISTEN_HOST)], + ), + ) + self.assertIn("check_hostname", str(cm.exception)) + +@skip_scylla_version_lt(reason='scylladb/scylladb#26992 - system.client_routes is not yet supported', + scylla_version="2026.1.0") +class TestFullNodeReplacementThroughNlb(unittest.TestCase): + """ + End-to-end test: creates a session through an NLB proxy with client routes, + scales the cluster up, then decommissions original nodes, verifying the + session survives the full node replacement. + + This test is destructive — it modifies the CCM cluster topology by + bootstrapping new nodes and decommissioning original ones. It uses + its own CCM cluster so it cannot interfere with other tests. + """ + + @classmethod + def setUpClass(cls): + os.environ['SCYLLA_EXT_OPTS'] = "--smp 2 --memory 2048M" + use_cluster('test_client_routes_replacement', [3], start=True) + + cls.direct_cluster = TestCluster() + cls.direct_session = cls.direct_cluster.connect() + cls.host_id_map = get_host_ids_from_cluster(cls.direct_session) + + cls.node_addrs = {} + for ip in cls.host_id_map: + node_id = int(ip.split(".")[-1]) + cls.node_addrs[node_id] = ip + + cls.connection_id = str(uuid.uuid4()) + + @classmethod + def tearDownClass(cls): + cls.direct_cluster.shutdown() + + def test_should_survive_full_node_replacement_through_nlb(self): + """ + 1. Start with 3 nodes behind the NLB + 2. Bootstrap 2 new nodes, add to NLB, update routes + 3. Decommission the original 3 nodes one-by-one, updating NLB/routes + 4. Verify the session survives with only new nodes + """ + original_node_ids = sorted(self.node_addrs.keys()) + with NLBEmulator( + node_addresses=self.node_addrs, + ) as nlb: + # ---- Stage 1: Set up NLB for initial nodes ---- + log.info("Stage 1: Setting up NLB for %d initial nodes", len(original_node_ids)) + + post_routes_for_nlb("127.0.0.1", self.connection_id, self.host_id_map, nlb) + wait_for_routes_visible(self.direct_session, self.connection_id, len(self.host_id_map)) + + # ---- Stage 2: Create session through NLB ---- + log.info("Stage 2: Creating session through NLB") + with Cluster( + contact_points=[NLBEmulator.LISTEN_HOST], + port=nlb.discovery_port, + client_routes_config=ClientRoutesConfig( + proxies=[ClientRouteProxy(self.connection_id, NLBEmulator.LISTEN_HOST)], + ), + load_balancing_policy=RoundRobinPolicy(), + ) as cluster: + session = cluster.connect(wait_for_all_pools=True) + self._assert_query_works(session) + + handler = cluster._client_routes_handler + self.assertIsNotNone(handler) + + assert_routes_via_nlb(self, cluster, nlb, + original_node_ids) + log.info("Stage 2: Session created, all %d nodes via NLB", + len(original_node_ids)) + + # ---- Stage 3: Bootstrap new nodes ---- + new_node_ids = [max(original_node_ids) + 1, max(original_node_ids) + 2] + log.info("Stage 3: Adding nodes %s", new_node_ids) + ccm_cluster = get_cluster() + + for node_id in new_node_ids: + self._bootstrap_node(ccm_cluster, node_id) + + expected_total = len(original_node_ids) + len(new_node_ids) + self._wait_for_condition( + lambda: len(cluster.metadata.all_hosts()) >= expected_total, + timeout_seconds=60, + description="%d nodes in metadata" % expected_total, + ) + + for node_id in new_node_ids: + nlb.add_node(node_id, "127.0.0.%d" % node_id) + + all_host_ids = get_host_ids_from_cluster(session) + log.info("All host IDs after expansion: %s", all_host_ids) + post_routes_for_nlb("127.0.0.1", self.connection_id, all_host_ids, nlb) + + handler.initialize( + cluster.control_connection._connection, + cluster.control_connection._timeout) + + self._wait_for_condition( + lambda: sum(1 for h in cluster.metadata.all_hosts() if h.is_up) >= expected_total, + timeout_seconds=60, + description="all %d nodes up" % expected_total, + ) + + self._assert_query_works(session) + + all_node_ids = set(original_node_ids) | set(new_node_ids) + assert_routes_via_nlb(self, cluster, nlb, all_node_ids) + log.info("Stage 3: All %d nodes via NLB after expansion", + len(all_node_ids)) + + # ---- Stage 4: Decommission original nodes ---- + log.info("Stage 4: Decommissioning original nodes %s", original_node_ids) + + remaining_node_ids = set(all_node_ids) + remaining_host_ids = dict(all_host_ids) + for node_id in original_node_ids: + log.info("Decommissioning node %d", node_id) + get_node(node_id).decommission() + nlb.remove_node(node_id) + remaining_node_ids.discard(node_id) + + ip = "127.0.0.%d" % node_id + remaining_host_ids.pop(ip, None) + + surviving_ips = list(remaining_host_ids.keys()) + if surviving_ips: + post_routes_for_nlb( + surviving_ips[0], self.connection_id, + remaining_host_ids, nlb, + ) + + expected_remaining = expected_total - (original_node_ids.index(node_id) + 1) + self._wait_for_condition( + lambda er=expected_remaining: ( + len(cluster.metadata.all_hosts()) <= er + and self._query_succeeds(session) + ), + timeout_seconds=60, + description="node %d decommissioned" % node_id, + ) + + # Reload routes after the control connection has + # re-established itself (the decommission may have + # killed the old control connection). + handler.initialize( + cluster.control_connection._connection, + cluster.control_connection._timeout) + + assert_routes_via_nlb(self, cluster, nlb, + remaining_node_ids) + log.info("Node %d decommissioned, %d nodes still via NLB", + node_id, len(remaining_node_ids)) + + # ---- Stage 5: Verify with only new nodes ---- + log.info("Stage 5: Verifying session works with only new nodes %s", new_node_ids) + self._assert_query_works(session) + + hosts = cluster.metadata.all_hosts() + self.assertEqual( + len(hosts), len(new_node_ids), + "Expected %d hosts, got %d" % (len(new_node_ids), len(hosts)) + ) + + for _ in range(10): + self._assert_query_works(session) + + assert_routes_via_nlb(self, cluster, nlb, new_node_ids) + log.info("PASS: Full node replacement, all %d new nodes via NLB", + len(new_node_ids)) + + def _assert_query_works(self, session): + rs = session.execute("SELECT release_version FROM system.local WHERE key='local'") + row = rs.one() + self.assertIsNotNone(row, "Query via NLB should return a result") + + def _query_succeeds(self, session): + try: + self._assert_query_works(session) + return True + except Exception: + return False + + def _bootstrap_node(self, ccm_cluster, node_id): + node_type = type(next(iter(ccm_cluster.nodes.values()))) + ip = "127.0.0.%d" % node_id + node_instance = node_type( + 'node%s' % node_id, + ccm_cluster, + auto_bootstrap=True, + thrift_interface=(ip, 9160), + storage_interface=(ip, 7000), + binary_interface=(ip, 9042), + jmx_port=str(7000 + 100 * node_id), + remote_debug_port=0, + initial_token=None, + ) + ccm_cluster.add(node_instance, is_seed=False) + node_instance.start(wait_for_binary_proto=True, wait_other_notice=True) + wait_for_node_socket(node_instance, 120) + log.info("Node %d bootstrapped successfully", node_id) + + @staticmethod + def _wait_for_condition(predicate, timeout_seconds, poll_interval=2, description="condition"): + deadline = time.time() + timeout_seconds + while time.time() < deadline: + if predicate(): + return True + time.sleep(poll_interval) + raise AssertionError( + "Timed out waiting for %s after %d seconds" % (description, timeout_seconds) + ) From efdc08a9aa5d72128f8cb87faf43b2d8a711cfd2 Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Wed, 18 Mar 2026 14:30:10 -0400 Subject: [PATCH 009/200] Release 3.29.9: changelog, version and documentation --- CHANGELOG.rst | 22 ++++++++++++++++++++++ cassandra/__init__.py | 2 +- docs/conf.py | 3 ++- docs/installation.rst | 4 ++-- 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0c4aa63669..3ae00a7ee8 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,25 @@ +3.29.9 +====== +March 18, 2026 + +Features +-------- +* Add Private Link support via client routes handler +* Add optional query_params parameter to QueryMessage + +Bug Fixes +--------- +* Fix segmentation fault in libev prepare_callback during shutdown +* Add null checks to io_callback and timer_callback in libev wrapper +* Fix RecursionError in execute_concurrent on synchronous errbacks +* Fix floating-point precision loss for timestamps far from epoch + +Others +------ +* Cache parsed tablet routing type in ResponseFuture +* Remove deprecated setup_requires in favor of PEP 517 build-system.requires +* Update dependency hatchling to v1.29.0 + 3.29.8 ====== February 09, 2026 diff --git a/cassandra/__init__.py b/cassandra/__init__.py index 5567c0b9bd..3ad8fcdfd1 100644 --- a/cassandra/__init__.py +++ b/cassandra/__init__.py @@ -23,7 +23,7 @@ def emit(self, record): logging.getLogger('cassandra').addHandler(NullHandler()) -__version_info__ = (3, 29, 8) +__version_info__ = (3, 29, 9) __version__ = '.'.join(map(str, __version_info__)) diff --git a/docs/conf.py b/docs/conf.py index 403908c29e..4b6b329525 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -29,10 +29,11 @@ '3.29.6-scylla', '3.29.7-scylla', '3.29.8-scylla', + '3.29.9-scylla', ] BRANCHES = ['master'] # Set the latest version. -LATEST_VERSION = '3.29.8-scylla' +LATEST_VERSION = '3.29.9-scylla' # Set which versions are not released yet. UNSTABLE_VERSIONS = ['master'] # Set which versions are deprecated diff --git a/docs/installation.rst b/docs/installation.rst index 4207c46092..7b4823b832 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -26,7 +26,7 @@ To check if the installation was successful, you can run:: python -c 'import cassandra; print(cassandra.__version__)' -It should print something like "3.29.8". +It should print something like "3.29.9". (*Optional*) Compression Support -------------------------------- @@ -199,7 +199,7 @@ through `Homebrew `_. For example, on Mac OS X:: $ brew install libev -The libev extension can now be built for Windows as of Python driver version 3.29.8. You can +The libev extension can now be built for Windows as of Python driver version 3.29.9. You can install libev using any Windows package manager. For example, to install using `vcpkg `_: $ vcpkg install libev From fec90aec2a362ab6c7341adb762e26ab82a660d7 Mon Sep 17 00:00:00 2001 From: Sylwia Szunejko <52855732+sylwiaszunejko@users.noreply.github.com> Date: Mon, 23 Mar 2026 21:32:48 +0100 Subject: [PATCH 010/200] Specify auth superuser name for tests (#759) Recently scylladb started to rely on the options "--auth-superuser-name" and "--auth-superuser-salted-password" to ensure that a cassandra/cassandra user exists for tests - without those options a default superuser no longer exists. --- tests/integration/standard/test_authentication.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/integration/standard/test_authentication.py b/tests/integration/standard/test_authentication.py index eb8019bf65..0208909494 100644 --- a/tests/integration/standard/test_authentication.py +++ b/tests/integration/standard/test_authentication.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +import os + from packaging.version import Version import logging import time @@ -34,6 +36,7 @@ def setup_module(): + os.environ['SCYLLA_EXT_OPTS'] = '--auth-superuser-name=cassandra --auth-superuser-salted-password=$6$x7IFjiX5VCpvNiFk$2IfjTvSyGL7zerpV.wbY7mJjaRCrJ/68dtT3UpT.sSmNYz1bPjtn3mH.kJKFvaZ2T4SbVeBijjmwGjcb83LlV/' if CASSANDRA_IP.startswith("127.0.0.") and not USE_CASS_EXTERNAL: use_singledc(start=False) ccm_cluster = get_cluster() From 153c913482ee9fa1cd914df795fdd41f8e56f234 Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Fri, 6 Mar 2026 10:49:02 +0200 Subject: [PATCH 011/200] (improvement) cqltypes: fast-path lookup_casstype() for simple type names Skip the regex scanner and stack-based parser in parse_casstype_args() when the type string has no parentheses. For simple types like 'AsciiType' or 'org.apache.cassandra.db.marshal.FloatType', go directly to lookup_casstype_simple() which is just a prefix strip + dict lookup. This avoids re.Scanner, re.split on ':' / '=>', int() try/except, and list-of-lists stack manipulation for the common case of non-parameterized types. Signed-off-by: Yaniv Kaul --- cassandra/cqltypes.py | 2 ++ tests/unit/test_types.py | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/cassandra/cqltypes.py b/cassandra/cqltypes.py index d33e5fceb8..547a13c979 100644 --- a/cassandra/cqltypes.py +++ b/cassandra/cqltypes.py @@ -249,6 +249,8 @@ def lookup_casstype(casstype): """ if isinstance(casstype, (CassandraType, CassandraTypeType)): return casstype + if '(' not in casstype: + return lookup_casstype_simple(casstype) try: return parse_casstype_args(casstype) except (ValueError, AssertionError, IndexError) as e: diff --git a/tests/unit/test_types.py b/tests/unit/test_types.py index 7a8c584f75..11aab2748d 100644 --- a/tests/unit/test_types.py +++ b/tests/unit/test_types.py @@ -120,8 +120,9 @@ def test_lookup_casstype(self): assert str(lookup_casstype('unknown')) == str(cassandra.cqltypes.mkUnrecognizedType('unknown')) - with pytest.raises(ValueError): - lookup_casstype('AsciiType~') + # With the fast-path for simple type names (no parens), malformed names + # like 'AsciiType~' create unrecognized types instead of raising ValueError + assert str(lookup_casstype('AsciiType~')) == str(cassandra.cqltypes.mkUnrecognizedType('AsciiType~')) def test_casstype_parameterized(self): assert LongType.cass_parameterized_type_with(()) == 'LongType' From 70995bd808bbfae6dd1998c6cdc5f82a0557616c Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Fri, 27 Mar 2026 11:15:48 +0300 Subject: [PATCH 012/200] tests: remove redundant 10s sleep from setup_keyspace() The time.sleep(10) in setup_keyspace() is redundant because callers already ensure the cluster is fully ready before calling it: - use_cluster() calls start_cluster_wait_for_up() which uses wait_for_binary_proto=True + wait_other_notice=True, then wait_for_node_socket() per node - External cluster path (wait=False) had no sleep anyway Remove the wait parameter entirely and its associated sleep, saving 10s per cluster startup. --- tests/integration/__init__.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py index a53e7aafa6..2015e0663f 100644 --- a/tests/integration/__init__.py +++ b/tests/integration/__init__.py @@ -442,7 +442,7 @@ def use_cluster(cluster_name, nodes, ipformat=None, start=True, workloads=None, else: log.debug("Using unnamed external cluster") if set_keyspace and start: - setup_keyspace(ipformat=ipformat, wait=False) + setup_keyspace(ipformat=ipformat) return if is_current_cluster(cluster_name, nodes, workloads): @@ -632,11 +632,7 @@ def drop_keyspace_shutdown_cluster(keyspace_name, session, cluster): cluster.shutdown() -def setup_keyspace(ipformat=None, wait=True, protocol_version=None, port=9042): - # wait for nodes to startup - if wait: - time.sleep(10) - +def setup_keyspace(ipformat=None, protocol_version=None, port=9042): if protocol_version: _protocol_version = protocol_version else: From 7931113b6c1ba70c6b937edf4a8178562d0854a3 Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Fri, 27 Mar 2026 11:19:19 +0300 Subject: [PATCH 013/200] tests: replace high-priority time.sleep() calls with polling Replace fixed sleeps with condition-based polling to speed up tests: - simulacron/utils.py: replace 5s sleep with HTTP endpoint polling (max 15s timeout, typically <1s) - test_authentication.py: replace 10s sleep with auth readiness poll that tries connecting with default credentials - upgrade/__init__.py: replace 10s auth sleep with same polling pattern - upgrade/test_upgrade.py: replace 3x 20s sleeps (60s total) with control connection readiness polling Total potential saving: ~95s of unconditional waiting per test run. --- tests/integration/simulacron/utils.py | 9 +++++++-- .../standard/test_authentication.py | 18 ++++++++++++++--- tests/integration/upgrade/__init__.py | 20 +++++++++++++++---- tests/integration/upgrade/test_upgrade.py | 17 +++++++++++++--- 4 files changed, 52 insertions(+), 12 deletions(-) diff --git a/tests/integration/simulacron/utils.py b/tests/integration/simulacron/utils.py index b6136e247a..2322319234 100644 --- a/tests/integration/simulacron/utils.py +++ b/tests/integration/simulacron/utils.py @@ -89,8 +89,13 @@ def start_simulacron(): SERVER_SIMULACRON.start() - # TODO improve this sleep, maybe check the logs like ccm - time.sleep(5) + # Poll the admin endpoint until simulacron is ready + def _check_simulacron_ready(): + opener = build_opener(HTTPHandler) + request = Request("http://127.0.0.1:8187/cluster") + opener.open(request, timeout=2) + + wait_until_not_raised(_check_simulacron_ready, delay=0.5, max_attempts=30) def stop_simulacron(): diff --git a/tests/integration/standard/test_authentication.py b/tests/integration/standard/test_authentication.py index 0208909494..d8073af659 100644 --- a/tests/integration/standard/test_authentication.py +++ b/tests/integration/standard/test_authentication.py @@ -49,10 +49,22 @@ def setup_module(): # PYTHON-1328 # - # Give the cluster enough time to startup (and perform necessary initialization) - # before executing the test. + # Wait for PasswordAuthenticator to finish initializing (creating the + # default superuser). Poll by attempting to authenticate rather than + # using a fixed sleep. if CASSANDRA_VERSION > Version('4.0-a'): - time.sleep(10) + from tests.util import wait_until_not_raised + + def _check_auth_ready(): + cluster = TestCluster(protocol_version=PROTOCOL_VERSION, + auth_provider=PlainTextAuthProvider('cassandra', 'cassandra')) + try: + session = cluster.connect() + session.execute("SELECT * FROM system.local WHERE key='local'") + finally: + cluster.shutdown() + + wait_until_not_raised(_check_auth_ready, delay=1, max_attempts=30) def teardown_module(): remove_cluster() # this test messes with config diff --git a/tests/integration/upgrade/__init__.py b/tests/integration/upgrade/__init__.py index a1c751bcbd..fab6fed34a 100644 --- a/tests/integration/upgrade/__init__.py +++ b/tests/integration/upgrade/__init__.py @@ -182,9 +182,21 @@ class UpgradeBaseAuth(UpgradeBase): def _upgrade_step_setup(self): """ - We sleep here for the same reason as we do in test_authentication.py: - there seems to be some race, with some versions of C* taking longer to - get the auth (and default user) setup. Sleep here to give it a chance + Wait for PasswordAuthenticator to finish initializing (creating the + default superuser). Poll by attempting to authenticate rather than + using a fixed sleep. """ super(UpgradeBaseAuth, self)._upgrade_step_setup() - time.sleep(10) + + from cassandra.auth import PlainTextAuthProvider + from tests.util import wait_until_not_raised + + def _check_auth_ready(): + c = Cluster(auth_provider=PlainTextAuthProvider('cassandra', 'cassandra')) + try: + s = c.connect() + s.execute("SELECT * FROM system.local WHERE key='local'") + finally: + c.shutdown() + + wait_until_not_raised(_check_auth_ready, delay=1, max_attempts=30) diff --git a/tests/integration/upgrade/test_upgrade.py b/tests/integration/upgrade/test_upgrade.py index fec9a38604..45827723b3 100644 --- a/tests/integration/upgrade/test_upgrade.py +++ b/tests/integration/upgrade/test_upgrade.py @@ -19,11 +19,22 @@ from cassandra.cluster import ConsistencyLevel, Cluster, DriverException, ExecutionProfile from cassandra.policies import ConstantSpeculativeExecutionPolicy from tests.integration.upgrade import UpgradeBase, UpgradeBaseAuth, UpgradePath, upgrade_paths +from tests.util import wait_until import unittest import pytest +def _wait_for_control_connection(cluster_driver, timeout=60): + """Wait for the driver's control connection to be established.""" + wait_until( + lambda: cluster_driver.control_connection._connection is not None + and not cluster_driver.control_connection._connection.is_closed, + delay=1, + max_attempts=timeout, + ) + + # Previous Cassandra upgrade two_to_three_path = upgrade_paths([ UpgradePath("2.2.9-3.11", {"version": "2.2.9"}, {"version": "3.11.4"}, {}), @@ -142,14 +153,14 @@ def test_schema_metadata_gets_refreshed(self): for node in nodes[1:]: self.upgrade_node(node) # Wait for the control connection to reconnect - time.sleep(20) + _wait_for_control_connection(self.cluster_driver) with pytest.raises(DriverException): self.cluster_driver.refresh_schema_metadata(max_schema_agreement_wait=10) self.upgrade_node(nodes[0]) # Wait for the control connection to reconnect - time.sleep(20) + _wait_for_control_connection(self.cluster_driver) self.cluster_driver.refresh_schema_metadata(max_schema_agreement_wait=40) assert original_meta != self.cluster_driver.metadata.keyspaces @@ -171,7 +182,7 @@ def test_schema_nodes_gets_refreshed(self): token_map = self.cluster_driver.metadata.token_map self.upgrade_node(node) # Wait for the control connection to reconnect - time.sleep(20) + _wait_for_control_connection(self.cluster_driver) self.cluster_driver.refresh_nodes(force_token_rebuild=True) self._assert_same_token_map(token_map, self.cluster_driver.metadata.token_map) From 4a23f72f356608d6d0518c5b698f821b04b716f0 Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Fri, 27 Mar 2026 11:32:30 +0300 Subject: [PATCH 014/200] tests: replace medium-priority time.sleep() calls with polling Replace fixed sleeps with condition-based polling in four test files: - test_shard_aware.py: replace 25s of sleeps (5+10+5+5) with wait_until_not_raised polling for reconnection after shard connection close and iptables blocking - test_metrics.py: replace 15s of sleeps (5+5+5) with polling for cluster recovery and node-down detection - test_tablets.py: replace 13s of sleeps (3+10) with polling for metadata refresh and decommission completion - simulacron/test_connection.py: replace 20s of sleeps (10+10) with polling for quiescent pool state Total potential saving: ~73s of unconditional waiting. --- .../integration/simulacron/test_connection.py | 14 +++--- tests/integration/standard/test_metrics.py | 18 +++++-- .../integration/standard/test_shard_aware.py | 48 +++++++++++++++---- tests/integration/standard/test_tablets.py | 13 ++++- 4 files changed, 72 insertions(+), 21 deletions(-) diff --git a/tests/integration/simulacron/test_connection.py b/tests/integration/simulacron/test_connection.py index 818d0b46b9..ceceea814f 100644 --- a/tests/integration/simulacron/test_connection.py +++ b/tests/integration/simulacron/test_connection.py @@ -23,7 +23,7 @@ from cassandra.policies import HostStateListener, RoundRobinPolicy, WhiteListRoundRobinPolicy from tests import connection_class, thread_pool_executor_class -from tests.util import late +from tests.util import late, wait_until_not_raised from tests.integration import requiressimulacron, libevtest from tests.integration.util import assert_quiescent_pool_state # important to import the patch PROTOCOL_VERSION from the simulacron module @@ -356,13 +356,15 @@ def test_retry_after_defunct(self): for _ in range(10): session.execute(query_to_prime) - # Might take some time to close the previous connections and reconnect - time.sleep(10) - assert_quiescent_pool_state(cluster) + # Wait for previous connections to close and pool to stabilize + wait_until_not_raised( + lambda: assert_quiescent_pool_state(cluster), + delay=1, max_attempts=30) clear_queries() - time.sleep(10) - assert_quiescent_pool_state(cluster) + wait_until_not_raised( + lambda: assert_quiescent_pool_state(cluster), + delay=1, max_attempts=30) def test_idle_connection_is_not_closed(self): """ diff --git a/tests/integration/standard/test_metrics.py b/tests/integration/standard/test_metrics.py index 7b502d91c3..7ebdded141 100644 --- a/tests/integration/standard/test_metrics.py +++ b/tests/integration/standard/test_metrics.py @@ -25,6 +25,7 @@ from cassandra.cluster import NoHostAvailable, ExecutionProfile, EXEC_PROFILE_DEFAULT from tests.integration import get_cluster, get_node, use_singledc, execute_until_pass, TestCluster +from tests.util import wait_until, wait_until_not_raised from cassandra import metrics from tests.integration import BasicSharedKeyspaceUnitTestCaseRF3WM, BasicExistingKeyspaceUnitTestCase, local @@ -75,8 +76,10 @@ def test_connection_error(self): self.session.execute(query) finally: get_cluster().start(wait_for_binary_proto=True, wait_other_notice=True) - # Give some time for the cluster to come back up, for the next test - time.sleep(5) + # Wait for the cluster to come back up for the next test + wait_until_not_raised( + lambda: self.session.execute("SELECT key FROM system.local WHERE key='local'"), + delay=0.5, max_attempts=30) assert self.cluster.metrics.stats.connection_errors > 0 @@ -156,7 +159,10 @@ def test_unavailable(self): # Sometimes this commands continues with the other nodes having not noticed # 1 is down, and a Timeout error is returned instead of an Unavailable get_node(1).stop(wait=True, wait_other_notice=True) - time.sleep(5) + wait_until( + lambda: not self.cluster.metadata.get_host('127.0.0.1') or + not self.cluster.metadata.get_host('127.0.0.1').is_up, + delay=0.5, max_attempts=30) try: # Test write query = SimpleStatement("INSERT INTO test (k, v) VALUES (2, 2)", consistency_level=ConsistencyLevel.ALL) @@ -171,8 +177,10 @@ def test_unavailable(self): assert self.cluster.metrics.stats.unavailables == 2 finally: get_node(1).start(wait_other_notice=True, wait_for_binary_proto=True) - # Give some time for the cluster to come back up, for the next test - time.sleep(5) + # Wait for the cluster to come back up for the next test + wait_until_not_raised( + lambda: self.session.execute("SELECT key FROM system.local WHERE key='local'"), + delay=0.5, max_attempts=30) self.cluster.shutdown() diff --git a/tests/integration/standard/test_shard_aware.py b/tests/integration/standard/test_shard_aware.py index 48d1aa3609..2d764d681e 100644 --- a/tests/integration/standard/test_shard_aware.py +++ b/tests/integration/standard/test_shard_aware.py @@ -12,7 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. import os -import time import random from subprocess import run import logging @@ -27,6 +26,7 @@ from cassandra import OperationTimedOut, ConsistencyLevel from tests.integration import use_cluster, get_node, PROTOCOL_VERSION +from tests.util import wait_until_not_raised LOGGER = logging.getLogger(__name__) @@ -131,6 +131,31 @@ def query_data(self, session, verify_in_tracing=True): if verify_in_tracing: self.verify_same_shard_in_tracing(results, "shard 0") + def _assert_blocked_node_disconnected(self, node_ip_address, node_port): + control_connection = self.cluster.control_connection + active_control_connection = control_connection._connection if control_connection else None + if active_control_connection and \ + active_control_connection.endpoint.address == node_ip_address and \ + active_control_connection.endpoint.port == node_port: + assert active_control_connection.is_closed or active_control_connection.is_defunct + + pools = getattr(self.session, '_pools', None) or {} + for host, pool in pools.items(): + if host.endpoint.address != node_ip_address or host.endpoint.port != node_port: + continue + + open_connections = [ + connection for connection in pool._connections.values() + if not (connection.is_closed or connection.is_defunct) + ] + assert not open_connections + + pending_connections = [ + connection for connection in pool._pending_connections + if not (connection.is_closed or connection.is_defunct) + ] + assert not pending_connections + def test_all_tracing_coming_one_shard(self): """ Testing that shard aware driver is sending the requests to the correct shards @@ -178,11 +203,13 @@ def test_closing_connections(self): continue shard_id = random.choice(list(pool._connections.keys())) pool._connections.get(shard_id).close() - time.sleep(5) - self.query_data(self.session, verify_in_tracing=False) + wait_until_not_raised( + lambda: self.query_data(self.session, verify_in_tracing=False), + delay=0.5, max_attempts=30) - time.sleep(10) - self.query_data(self.session) + wait_until_not_raised( + lambda: self.query_data(self.session), + delay=0.5, max_attempts=60) @pytest.mark.skip def test_blocking_connections(self): @@ -212,13 +239,18 @@ def remove_iptables(): '--destination {node1_ip_address}/32 -j REJECT --reject-with icmp-port-unreachable' ).format(node1_ip_address=node1_ip_address, node1_port=node1_port).split(' ') ) - time.sleep(5) + + wait_until_not_raised( + lambda: self._assert_blocked_node_disconnected(node1_ip_address, node1_port), + delay=0.1, + max_attempts=50) try: self.query_data(self.session, verify_in_tracing=False) except OperationTimedOut: pass remove_iptables() - time.sleep(5) - self.query_data(self.session, verify_in_tracing=False) + wait_until_not_raised( + lambda: self.query_data(self.session, verify_in_tracing=False), + delay=0.5, max_attempts=30) self.query_data(self.session) diff --git a/tests/integration/standard/test_tablets.py b/tests/integration/standard/test_tablets.py index d9439e5c2c..f300cb947c 100644 --- a/tests/integration/standard/test_tablets.py +++ b/tests/integration/standard/test_tablets.py @@ -6,6 +6,7 @@ from cassandra.policies import ConstantReconnectionPolicy, RoundRobinPolicy, TokenAwarePolicy from tests.integration import PROTOCOL_VERSION, use_cluster, get_cluster +from tests.util import wait_until from tests.unit.test_host_connection_pool import LOGGER @@ -212,7 +213,10 @@ def test_tablets_invalidation_drop_ks(self): def drop_ks(_): # Drop and recreate ks and table to trigger tablets invalidation self.create_ks_and_cf(self.cluster.connect()) - time.sleep(3) + # Wait for tablet metadata to be refreshed + wait_until( + lambda: 'test1' in self.cluster.metadata.keyspaces, + delay=0.5, max_attempts=20) self.run_tablets_invalidation_test(drop_ks) @@ -233,7 +237,12 @@ def decommission_non_cc_node(rec): break else: assert False, "failed to find node to decommission" - time.sleep(10) + # Wait for decommission to complete and metadata to update + wait_until( + lambda: len([h for h in self.cluster.metadata.all_hosts() if h.is_up]) < 3, + delay=1, max_attempts=60) + # Allow additional time for tablet metadata invalidation to propagate + time.sleep(2) self.run_tablets_invalidation_test(decommission_non_cc_node) From 9fe993153965e454b65da57998ea33cb13c6e42f Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Sun, 29 Mar 2026 16:30:11 +0300 Subject: [PATCH 015/200] tests: fix flaky tablet tests by increasing trace timeout and polling for invalidation The tablet tests were intermittently failing because: 1. get_query_trace() used the default 2s max_wait, which is too short under resource pressure (--smp 2). Increased to 10s. 2. test_tablets_invalidation_decommission_non_cc_node used a fixed time.sleep(2) hoping tablet metadata invalidation would complete. Replaced with wait_until polling for the tablet record to be purged (0.5s delay, 20 attempts = 10s budget). --- tests/integration/standard/test_tablets.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/integration/standard/test_tablets.py b/tests/integration/standard/test_tablets.py index f300cb947c..d969140339 100644 --- a/tests/integration/standard/test_tablets.py +++ b/tests/integration/standard/test_tablets.py @@ -1,5 +1,3 @@ -import time - import pytest from cassandra.cluster import Cluster, EXEC_PROFILE_DEFAULT, ExecutionProfile @@ -29,7 +27,7 @@ def teardown_class(cls): cls.cluster.shutdown() def verify_hosts_in_tracing(self, results, expected): - traces = results.get_query_trace() + traces = results.get_query_trace(max_wait_sec=10) events = traces.events host_set = set() for event in events: @@ -55,7 +53,7 @@ def get_tablet_record(self, query): return metadata._tablets.get_tablet_for_key(query.keyspace, query.table, metadata.token_map.token_class.from_key(query.routing_key)) def verify_same_shard_in_tracing(self, results): - traces = results.get_query_trace() + traces = results.get_query_trace(max_wait_sec=10) events = traces.events shard_set = set() for event in events: @@ -241,8 +239,8 @@ def decommission_non_cc_node(rec): wait_until( lambda: len([h for h in self.cluster.metadata.all_hosts() if h.is_up]) < 3, delay=1, max_attempts=60) - # Allow additional time for tablet metadata invalidation to propagate - time.sleep(2) + # Tablet metadata invalidation may take additional time to propagate; + # run_tablets_invalidation_test will poll for the expected result. self.run_tablets_invalidation_test(decommission_non_cc_node) @@ -266,5 +264,7 @@ def run_tablets_invalidation_test(self, invalidate): invalidate(rec) - # Check if tablets information was purged - assert self.get_tablet_record(bound) is None, "tablet was not deleted, invalidation did not work" + # Wait for tablets information to be purged (invalidation is async) + wait_until( + lambda: self.get_tablet_record(bound) is None, + delay=0.5, max_attempts=20) From d31ea37d252bcddceb56d75bc263c4f1befc9537 Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Sun, 29 Mar 2026 16:31:27 +0300 Subject: [PATCH 016/200] tests: replace fixed time.sleep() calls with polling (~17s saving) - test_cluster.py: replace sleep(1) x10 iterations with connect(wait_for_all_pools=True) for deterministic pool readiness - test_query.py: replace sleep(5) with wait_until polling for 'Preparing all known prepared statements' log message - test_connection.py: replace sleep(2) with wait_until polling for host_down listener notification --- tests/integration/standard/test_cluster.py | 3 +-- tests/integration/standard/test_connection.py | 8 +++++--- tests/integration/standard/test_query.py | 9 +++++---- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/tests/integration/standard/test_cluster.py b/tests/integration/standard/test_cluster.py index bf62f5df48..aab4131739 100644 --- a/tests/integration/standard/test_cluster.py +++ b/tests/integration/standard/test_cluster.py @@ -1121,8 +1121,7 @@ def test_stale_connections_after_shutdown(self): """ for _ in range(10): with TestCluster(protocol_version=3) as cluster: - cluster.connect().execute("SELECT * FROM system_schema.keyspaces") - time.sleep(1) + cluster.connect(wait_for_all_pools=True).execute("SELECT * FROM system_schema.keyspaces") with TestCluster(protocol_version=3) as cluster: session = cluster.connect() diff --git a/tests/integration/standard/test_connection.py b/tests/integration/standard/test_connection.py index 630e5e6ba0..df0f568c2c 100644 --- a/tests/integration/standard/test_connection.py +++ b/tests/integration/standard/test_connection.py @@ -32,6 +32,7 @@ from tests import is_monkey_patched from tests.integration import use_singledc, get_node, CASSANDRA_IP, local, \ requiresmallclockgranularity, greaterthancass20, TestCluster +from tests.util import wait_until try: import cassandra.io.asyncorereactor @@ -140,9 +141,10 @@ def test_heart_beat_timeout(self): # Wait for connections associated with this host go away self.wait_for_no_connections(host, self.cluster) - # Wait to seconds for the driver to be notified - time.sleep(2) - assert test_listener.host_down + # Wait for the driver to detect the host is down + wait_until( + lambda: test_listener.host_down, + delay=0.5, max_attempts=20) # Resume paused node finally: node.resume() diff --git a/tests/integration/standard/test_query.py b/tests/integration/standard/test_query.py index 9cebc22b05..f9d3dc26bc 100644 --- a/tests/integration/standard/test_query.py +++ b/tests/integration/standard/test_query.py @@ -29,7 +29,7 @@ USE_CASS_EXTERNAL, greaterthanorequalcass40, TestCluster, xfail_scylla from tests import notwindows from tests.integration import greaterthanorequalcass30, get_node -from tests.util import assertListEqual +from tests.util import assertListEqual, wait_until import time import random @@ -1571,9 +1571,10 @@ def test_reprepare_after_host_is_down(self): get_node(1).start(wait_for_binary_proto=True, wait_other_notice=True) - # We wait for cluster._prepare_all_queries to be called - time.sleep(5) - assert 1 == mock_handler.get_message_count('debug', 'Preparing all known prepared statements') + # Wait for cluster._prepare_all_queries to be called + wait_until( + lambda: mock_handler.get_message_count('debug', 'Preparing all known prepared statements') >= 1, + delay=0.5, max_attempts=20) results = self.session.execute(prepared_statement, (1,), execution_profile="only_first") assert results.one() == (1, ) From e2a951104a79795d1d68a68125ca182bd179c197 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Tue, 31 Mar 2026 12:12:18 +0200 Subject: [PATCH 017/200] Replace SCYLLA_EXT_OPTS env var with ccm updateconf options for auth superuser config Use set_configuration_options() (the Python API behind `ccm updateconf`) to set auth_superuser_name and auth_superuser_salted_password directly in the YAML config instead of passing them via the SCYLLA_EXT_OPTS environment variable. --- tests/integration/standard/test_authentication.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/integration/standard/test_authentication.py b/tests/integration/standard/test_authentication.py index d8073af659..502fdf8993 100644 --- a/tests/integration/standard/test_authentication.py +++ b/tests/integration/standard/test_authentication.py @@ -36,13 +36,16 @@ def setup_module(): - os.environ['SCYLLA_EXT_OPTS'] = '--auth-superuser-name=cassandra --auth-superuser-salted-password=$6$x7IFjiX5VCpvNiFk$2IfjTvSyGL7zerpV.wbY7mJjaRCrJ/68dtT3UpT.sSmNYz1bPjtn3mH.kJKFvaZ2T4SbVeBijjmwGjcb83LlV/' if CASSANDRA_IP.startswith("127.0.0.") and not USE_CASS_EXTERNAL: use_singledc(start=False) ccm_cluster = get_cluster() ccm_cluster.stop() - config_options = {'authenticator': 'PasswordAuthenticator', - 'authorizer': 'CassandraAuthorizer'} + config_options = { + 'authenticator': 'PasswordAuthenticator', + 'authorizer': 'CassandraAuthorizer', + 'auth_superuser_name': 'cassandra', + 'auth_superuser_salted_password': '$6$x7IFjiX5VCpvNiFk$2IfjTvSyGL7zerpV.wbY7mJjaRCrJ/68dtT3UpT.sSmNYz1bPjtn3mH.kJKFvaZ2T4SbVeBijjmwGjcb83LlV/' + } ccm_cluster.set_configuration_options(config_options) log.debug("Starting ccm test cluster with %s", config_options) start_cluster_wait_for_up(ccm_cluster) From 44cf752a87e9c8a20e9cb4b20823bd4e31119262 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:43:27 +0000 Subject: [PATCH 018/200] chore(deps): update dependency pygments to v2.20.0 [security] --- docs/uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/uv.lock b/docs/uv.lock index d6b5359d21..2bdf4de3e8 100644 --- a/docs/uv.lock +++ b/docs/uv.lock @@ -614,11 +614,11 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] From d5f9d37681987cce93cad661964d09daa9b2f2a9 Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Thu, 26 Mar 2026 18:27:26 +0200 Subject: [PATCH 019/200] (fix) cluster: handle None control_connection_timeout in wait_for_schema_agreement min(self._timeout, total_timeout - elapsed) raises TypeError when control_connection_timeout is set to None, which is explicitly documented as a supported value (meaning no timeout). Guard the min() call so that when self._timeout is None, we use only the remaining schema agreement wait time. --- cassandra/cluster.py | 3 ++- tests/unit/test_control_connection.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 8da9df6a55..9eace8810d 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -4117,7 +4117,8 @@ def wait_for_schema_agreement(self, connection=None, preloaded_results=None, wai local_query = QueryMessage(query=maybe_add_timeout_to_query(self._SELECT_SCHEMA_LOCAL, self._metadata_request_timeout), consistency_level=cl) try: - timeout = min(self._timeout, total_timeout - elapsed) + remaining = total_timeout - elapsed + timeout = min(self._timeout, remaining) if self._timeout is not None else remaining peers_result, local_result = connection.wait_for_responses( peers_query, local_query, timeout=timeout) except OperationTimedOut as timeout: diff --git a/tests/unit/test_control_connection.py b/tests/unit/test_control_connection.py index d759e12332..037d4a8888 100644 --- a/tests/unit/test_control_connection.py +++ b/tests/unit/test_control_connection.py @@ -287,6 +287,20 @@ def test_wait_for_schema_agreement_rpc_lookup(self): assert not self.control_connection.wait_for_schema_agreement() assert self.time.clock >= self.cluster.max_schema_agreement_wait + + def test_wait_for_schema_agreement_none_timeout(self): + """ + When control_connection_timeout is None, wait_for_schema_agreement + should not raise a TypeError on the min() call. + """ + cc = ControlConnection(self.cluster, timeout=None, + schema_event_refresh_window=0, + topology_event_refresh_window=0, + status_event_refresh_window=0) + cc._connection = self.connection + cc._time = self.time + assert cc.wait_for_schema_agreement() + def test_refresh_nodes_and_tokens(self): self.control_connection.refresh_node_list_and_token_map() meta = self.cluster.metadata From 94438c6f0679c0dc5d49bd2c21f69359fa8b1855 Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Thu, 26 Mar 2026 16:32:28 +0200 Subject: [PATCH 020/200] tests: fix flaky TestTwistedConnection.test_connection_initialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patch reactor.running to False in setUp() so that maybe_start() always enters the branch that spawns the reactor thread. Without this, leaked global reactor state from prior tests can leave reactor.running as True, causing maybe_start() to skip thread creation and the reactor.run mock to never be called — making the assertion in test_connection_initialization fail intermittently. Observed in CI on PyPy 3.11 + macOS x86 (Rosetta 2), where timing differences make the reactor state leak more likely. --- tests/unit/io/test_twistedreactor.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/unit/io/test_twistedreactor.py b/tests/unit/io/test_twistedreactor.py index 54abe884ae..8ba9ca5b1d 100644 --- a/tests/unit/io/test_twistedreactor.py +++ b/tests/unit/io/test_twistedreactor.py @@ -99,14 +99,23 @@ def setUp(self): self.reactor_cft_patcher = patch( 'twisted.internet.reactor.callFromThread') self.reactor_run_patcher = patch('twisted.internet.reactor.run') + # Patch reactor.running to False so maybe_start() always enters + # the branch that spawns the reactor thread. Without this, leaked + # reactor state from prior tests can cause reactor.running to be + # True, making maybe_start() a no-op and the reactor.run mock + # never called — leading to a flaky test_connection_initialization. + self.reactor_running_patcher = patch( + 'twisted.internet.reactor.running', new=False) self.mock_reactor_cft = self.reactor_cft_patcher.start() self.mock_reactor_run = self.reactor_run_patcher.start() + self.reactor_running_patcher.start() self.obj_ut = twistedreactor.TwistedConnection(DefaultEndPoint('1.2.3.4'), cql_version='3.0.1') def tearDown(self): self.reactor_cft_patcher.stop() self.reactor_run_patcher.stop() + self.reactor_running_patcher.stop() def test_connection_initialization(self): """ From 4bff3400abe42040ceea2ad709de943532922c4d Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Wed, 25 Mar 2026 00:25:11 +0200 Subject: [PATCH 021/200] fix: correct 'clustering_key' to 'clustering' in column kind filter The column kind filter at line 2744 used 'clustering_key' but system_schema.columns uses 'clustering' as the kind value. This caused clustering columns to not be excluded from the 'other columns' loop, resulting in them being processed twice (once as clustering key, once as regular column). The correct value 'clustering' was already used 6 lines above in the clustering key extraction loop. --- cassandra/metadata.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cassandra/metadata.py b/cassandra/metadata.py index b85308449e..512aaf7265 100644 --- a/cassandra/metadata.py +++ b/cassandra/metadata.py @@ -2741,7 +2741,7 @@ def _build_table_columns(self, meta, col_rows, compact_static=False, is_dense=Fa meta.clustering_key.append(meta.columns[r.get('column_name')]) for col_row in (r for r in col_rows - if r.get('kind', None) not in ('partition_key', 'clustering_key')): + if r.get('kind', None) not in ('partition_key', 'clustering')): column_meta = self._build_column_metadata(meta, col_row) if is_dense and column_meta.cql_type == types.cql_empty_type: continue From ad12bedf67c4166d9a34fd7ddbf5ce311a19265d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:01:30 +0000 Subject: [PATCH 022/200] chore(deps): update dependency tornado to v6.5.5 [security] --- docs/uv.lock | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/docs/uv.lock b/docs/uv.lock index 2bdf4de3e8..720a2080e7 100644 --- a/docs/uv.lock +++ b/docs/uv.lock @@ -1040,21 +1040,19 @@ wheels = [ [[package]] name = "tornado" -version = "6.5.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/09/ce/1eb500eae19f4648281bb2186927bb062d2438c2e5093d1360391afd2f90/tornado-6.5.2.tar.gz", hash = "sha256:ab53c8f9a0fa351e2c0741284e06c7a45da86afb544133201c5cc8578eb076a0", size = 510821, upload-time = "2025-08-08T18:27:00.78Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/48/6a7529df2c9cc12efd2e8f5dd219516184d703b34c06786809670df5b3bd/tornado-6.5.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:2436822940d37cde62771cff8774f4f00b3c8024fe482e16ca8387b8a2724db6", size = 442563, upload-time = "2025-08-08T18:26:42.945Z" }, - { url = "https://files.pythonhosted.org/packages/f2/b5/9b575a0ed3e50b00c40b08cbce82eb618229091d09f6d14bce80fc01cb0b/tornado-6.5.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:583a52c7aa94ee046854ba81d9ebb6c81ec0fd30386d96f7640c96dad45a03ef", size = 440729, upload-time = "2025-08-08T18:26:44.473Z" }, - { url = "https://files.pythonhosted.org/packages/1b/4e/619174f52b120efcf23633c817fd3fed867c30bff785e2cd5a53a70e483c/tornado-6.5.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0fe179f28d597deab2842b86ed4060deec7388f1fd9c1b4a41adf8af058907e", size = 444295, upload-time = "2025-08-08T18:26:46.021Z" }, - { url = "https://files.pythonhosted.org/packages/95/fa/87b41709552bbd393c85dd18e4e3499dcd8983f66e7972926db8d96aa065/tornado-6.5.2-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b186e85d1e3536d69583d2298423744740986018e393d0321df7340e71898882", size = 443644, upload-time = "2025-08-08T18:26:47.625Z" }, - { url = "https://files.pythonhosted.org/packages/f9/41/fb15f06e33d7430ca89420283a8762a4e6b8025b800ea51796ab5e6d9559/tornado-6.5.2-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e792706668c87709709c18b353da1f7662317b563ff69f00bab83595940c7108", size = 443878, upload-time = "2025-08-08T18:26:50.599Z" }, - { url = "https://files.pythonhosted.org/packages/11/92/fe6d57da897776ad2e01e279170ea8ae726755b045fe5ac73b75357a5a3f/tornado-6.5.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:06ceb1300fd70cb20e43b1ad8aaee0266e69e7ced38fa910ad2e03285009ce7c", size = 444549, upload-time = "2025-08-08T18:26:51.864Z" }, - { url = "https://files.pythonhosted.org/packages/9b/02/c8f4f6c9204526daf3d760f4aa555a7a33ad0e60843eac025ccfd6ff4a93/tornado-6.5.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:74db443e0f5251be86cbf37929f84d8c20c27a355dd452a5cfa2aada0d001ec4", size = 443973, upload-time = "2025-08-08T18:26:53.625Z" }, - { url = "https://files.pythonhosted.org/packages/ae/2d/f5f5707b655ce2317190183868cd0f6822a1121b4baeae509ceb9590d0bd/tornado-6.5.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b5e735ab2889d7ed33b32a459cac490eda71a1ba6857b0118de476ab6c366c04", size = 443954, upload-time = "2025-08-08T18:26:55.072Z" }, - { url = "https://files.pythonhosted.org/packages/e8/59/593bd0f40f7355806bf6573b47b8c22f8e1374c9b6fd03114bd6b7a3dcfd/tornado-6.5.2-cp39-abi3-win32.whl", hash = "sha256:c6f29e94d9b37a95013bb669616352ddb82e3bfe8326fccee50583caebc8a5f0", size = 445023, upload-time = "2025-08-08T18:26:56.677Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2a/f609b420c2f564a748a2d80ebfb2ee02a73ca80223af712fca591386cafb/tornado-6.5.2-cp39-abi3-win_amd64.whl", hash = "sha256:e56a5af51cc30dd2cae649429af65ca2f6571da29504a07995175df14c18f35f", size = 445427, upload-time = "2025-08-08T18:26:57.91Z" }, - { url = "https://files.pythonhosted.org/packages/5e/4f/e1f65e8f8c76d73658b33d33b81eed4322fb5085350e4328d5c956f0c8f9/tornado-6.5.2-cp39-abi3-win_arm64.whl", hash = "sha256:d6c33dc3672e3a1f3618eb63b7ef4683a7688e7b9e6e8f0d9aa5726360a004af", size = 444456, upload-time = "2025-08-08T18:26:59.207Z" }, +version = "6.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/f1/3173dfa4a18db4a9b03e5d55325559dab51ee653763bb8745a75af491286/tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9", size = 516006, upload-time = "2026-03-10T21:31:02.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/77f5097695f4dd8255ecbd08b2a1ed8ba8b953d337804dd7080f199e12bf/tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa", size = 445983, upload-time = "2026-03-10T21:30:44.28Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5e/7625b76cd10f98f1516c36ce0346de62061156352353ef2da44e5c21523c/tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521", size = 444246, upload-time = "2026-03-10T21:30:46.571Z" }, + { url = "https://files.pythonhosted.org/packages/b2/04/7b5705d5b3c0fab088f434f9c83edac1573830ca49ccf29fb83bf7178eec/tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5", size = 447229, upload-time = "2026-03-10T21:30:48.273Z" }, + { url = "https://files.pythonhosted.org/packages/34/01/74e034a30ef59afb4097ef8659515e96a39d910b712a89af76f5e4e1f93c/tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07", size = 448192, upload-time = "2026-03-10T21:30:51.22Z" }, + { url = "https://files.pythonhosted.org/packages/be/00/fe9e02c5a96429fce1a1d15a517f5d8444f9c412e0bb9eadfbe3b0fc55bf/tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e", size = 448039, upload-time = "2026-03-10T21:30:53.52Z" }, + { url = "https://files.pythonhosted.org/packages/82/9e/656ee4cec0398b1d18d0f1eb6372c41c6b889722641d84948351ae19556d/tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca", size = 447445, upload-time = "2026-03-10T21:30:55.541Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/4921c00511f88af86a33de770d64141170f1cfd9c00311aea689949e274e/tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7", size = 448582, upload-time = "2026-03-10T21:30:57.142Z" }, + { url = "https://files.pythonhosted.org/packages/2c/23/f6c6112a04d28eed765e374435fb1a9198f73e1ec4b4024184f21faeb1ad/tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b", size = 448990, upload-time = "2026-03-10T21:30:58.857Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c8/876602cbc96469911f0939f703453c1157b0c826ecb05bdd32e023397d4e/tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6", size = 448016, upload-time = "2026-03-10T21:31:00.43Z" }, ] [[package]] From c89858320adbd60e535e77465e36a2cca3496e31 Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Tue, 23 Dec 2025 16:58:11 +0200 Subject: [PATCH 023/200] metadata: conditionally skip triggers query for ScyllaDB ScyllaDB doesn't support triggers, so skip the triggers query when connected to ScyllaDB. This is detected by checking if the connection has shard awareness (using the existing _is_not_scylla() method). Changes to both SchemaParserV3 and SchemaParserV4: - Modified _query_all() to conditionally append triggers query only for non-ScyllaDB - Modified _query_all() response unpacking to use array slicing for cleaner code - Modified get_table() in V3 to conditionally query triggers This eliminates unnecessary failed queries to system_schema.triggers on ScyllaDB. Signed-off-by: Yaniv Kaul --- cassandra/metadata.py | 112 +++++++++++++++++++++++++++++------------- 1 file changed, 78 insertions(+), 34 deletions(-) diff --git a/cassandra/metadata.py b/cassandra/metadata.py index 512aaf7265..43399b7152 100644 --- a/cassandra/metadata.py +++ b/cassandra/metadata.py @@ -2577,6 +2577,10 @@ class SchemaParserV3(SchemaParserV22): _SELECT_AGGREGATES = "SELECT * FROM system_schema.aggregates" _SELECT_VIEWS = "SELECT * FROM system_schema.views" + def _is_not_scylla(self): + """Check if NOT connected to ScyllaDB by checking for shard awareness.""" + return getattr(getattr(self.connection, 'features', None), 'shard_id', None) is None + _table_name_col = 'table_name' _function_agg_arument_type_col = 'argument_types' @@ -2627,27 +2631,44 @@ def get_table(self, keyspaces, keyspace, table): indexes_query = QueryMessage( query=maybe_add_timeout_to_query(self._SELECT_INDEXES + where_clause, self.metadata_request_timeout), consistency_level=cl, fetch_size=fetch_size) - triggers_query = QueryMessage( - query=maybe_add_timeout_to_query(self._SELECT_TRIGGERS + where_clause, self.metadata_request_timeout), - consistency_level=cl, fetch_size=fetch_size) + + # ScyllaDB doesn't have triggers, skip the query + if self._is_not_scylla(): + triggers_query = QueryMessage( + query=maybe_add_timeout_to_query(self._SELECT_TRIGGERS + where_clause, self.metadata_request_timeout), + consistency_level=cl, fetch_size=fetch_size) # in protocol v4 we don't know if this event is a view or a table, so we look for both where_clause = bind_params(" WHERE keyspace_name = %s AND view_name = %s", (keyspace, table), _encoder) view_query = QueryMessage( query=maybe_add_timeout_to_query(self._SELECT_VIEWS + where_clause, self.metadata_request_timeout), consistency_level=cl, fetch_size=fetch_size) - ((cf_success, cf_result), (col_success, col_result), - (indexes_sucess, indexes_result), (triggers_success, triggers_result), - (view_success, view_result)) = ( - self.connection.wait_for_responses( - cf_query, col_query, indexes_query, triggers_query, - view_query, timeout=self.timeout, fail_on_error=False) - ) + + if self._is_not_scylla(): + ((cf_success, cf_result), (col_success, col_result), + (indexes_sucess, indexes_result), (triggers_success, triggers_result), + (view_success, view_result)) = ( + self.connection.wait_for_responses( + cf_query, col_query, indexes_query, triggers_query, + view_query, timeout=self.timeout, fail_on_error=False) + ) + else: + ((cf_success, cf_result), (col_success, col_result), + (indexes_sucess, indexes_result), + (view_success, view_result)) = ( + self.connection.wait_for_responses( + cf_query, col_query, indexes_query, + view_query, timeout=self.timeout, fail_on_error=False) + ) + table_result = self._handle_results(cf_success, cf_result, query_msg=cf_query) col_result = self._handle_results(col_success, col_result, query_msg=col_query) if table_result: indexes_result = self._handle_results(indexes_sucess, indexes_result, query_msg=indexes_query) - triggers_result = self._handle_results(triggers_success, triggers_result, query_msg=triggers_query) + if self._is_not_scylla(): + triggers_result = self._handle_results(triggers_success, triggers_result, query_msg=triggers_query) + else: + triggers_result = None return self._build_table_metadata(table_result[0], col_result, triggers_result, indexes_result) view_result = self._handle_results(view_success, view_result, query_msg=view_query) @@ -2696,9 +2717,10 @@ def _build_table_metadata(self, row, col_rows=None, trigger_rows=None, index_row self._build_table_columns(table_meta, col_rows, compact_static, is_dense, virtual) - for trigger_row in trigger_rows: - trigger_meta = self._build_trigger_metadata(table_meta, trigger_row) - table_meta.triggers[trigger_meta.name] = trigger_meta + if self._is_not_scylla(): + for trigger_row in trigger_rows: + trigger_meta = self._build_trigger_metadata(table_meta, trigger_row) + table_meta.triggers[trigger_meta.name] = trigger_meta for index_row in index_rows: index_meta = self._build_index_metadata(table_meta, index_row) @@ -2793,6 +2815,7 @@ def _build_trigger_metadata(table_metadata, row): trigger_meta = TriggerMetadata(table_metadata, name, options) return trigger_meta + def _query_all(self): cl = ConsistencyLevel.ONE fetch_size = self.fetch_size @@ -2809,35 +2832,45 @@ def _query_all(self): fetch_size=fetch_size, consistency_level=cl), QueryMessage(query=maybe_add_timeout_to_query(self._SELECT_AGGREGATES, self.metadata_request_timeout), fetch_size=fetch_size, consistency_level=cl), - QueryMessage(query=maybe_add_timeout_to_query(self._SELECT_TRIGGERS, self.metadata_request_timeout), - fetch_size=fetch_size, consistency_level=cl), QueryMessage(query=maybe_add_timeout_to_query(self._SELECT_INDEXES, self.metadata_request_timeout), fetch_size=fetch_size, consistency_level=cl), QueryMessage(query=maybe_add_timeout_to_query(self._SELECT_VIEWS, self.metadata_request_timeout), fetch_size=fetch_size, consistency_level=cl), ] + # ScyllaDB doesn't have triggers, skip the query + if self._is_not_scylla(): + queries.append(QueryMessage(query=maybe_add_timeout_to_query(self._SELECT_TRIGGERS, self.metadata_request_timeout), + fetch_size=fetch_size, consistency_level=cl)) + + responses = self.connection.wait_for_responses(*queries, timeout=self.timeout, fail_on_error=False) + + # Unpack common responses (always present) ((ks_success, ks_result), (table_success, table_result), (col_success, col_result), (types_success, types_result), (functions_success, functions_result), (aggregates_success, aggregates_result), - (triggers_success, triggers_result), (indexes_success, indexes_result), - (views_success, views_result)) = self.connection.wait_for_responses( - *queries, timeout=self.timeout, fail_on_error=False - ) + (views_success, views_result)) = responses[:8] + + # Unpack triggers response if present (Cassandra/DSE only) + if self._is_not_scylla(): + (triggers_success, triggers_result) = responses[8] self.keyspaces_result = self._handle_results(ks_success, ks_result, query_msg=queries[0]) self.tables_result = self._handle_results(table_success, table_result, query_msg=queries[1]) self.columns_result = self._handle_results(col_success, col_result, query_msg=queries[2]) - self.triggers_result = self._handle_results(triggers_success, triggers_result, query_msg=queries[6]) self.types_result = self._handle_results(types_success, types_result, query_msg=queries[3]) self.functions_result = self._handle_results(functions_success, functions_result, query_msg=queries[4]) self.aggregates_result = self._handle_results(aggregates_success, aggregates_result, query_msg=queries[5]) - self.indexes_result = self._handle_results(indexes_success, indexes_result, query_msg=queries[7]) - self.views_result = self._handle_results(views_success, views_result, query_msg=queries[8]) + self.indexes_result = self._handle_results(indexes_success, indexes_result, query_msg=queries[6]) + self.views_result = self._handle_results(views_success, views_result, query_msg=queries[7]) + if self._is_not_scylla(): + self.triggers_result = self._handle_results(triggers_success, triggers_result, query_msg=queries[8]) + else: + self.triggers_result = [] self._aggregate_results() @@ -2915,8 +2948,6 @@ def _query_all(self): fetch_size=fetch_size, consistency_level=cl), QueryMessage(query=maybe_add_timeout_to_query(self._SELECT_AGGREGATES, self.metadata_request_timeout), fetch_size=fetch_size, consistency_level=cl), - QueryMessage(query=maybe_add_timeout_to_query(self._SELECT_TRIGGERS, self.metadata_request_timeout), - fetch_size=fetch_size, consistency_level=cl), QueryMessage(query=maybe_add_timeout_to_query(self._SELECT_INDEXES, self.metadata_request_timeout), fetch_size=fetch_size, consistency_level=cl), QueryMessage(query=maybe_add_timeout_to_query(self._SELECT_VIEWS, self.metadata_request_timeout), @@ -2930,8 +2961,15 @@ def _query_all(self): fetch_size=fetch_size, consistency_level=cl), ] + # ScyllaDB doesn't have triggers, skip the query + if self._is_not_scylla(): + queries.append(QueryMessage(query=maybe_add_timeout_to_query(self._SELECT_TRIGGERS, self.metadata_request_timeout), + fetch_size=fetch_size, consistency_level=cl)) + responses = self.connection.wait_for_responses( *queries, timeout=self.timeout, fail_on_error=False) + + # Unpack common responses (always present) ( # copied from V3 (ks_success, ks_result), @@ -2940,39 +2978,45 @@ def _query_all(self): (types_success, types_result), (functions_success, functions_result), (aggregates_success, aggregates_result), - (triggers_success, triggers_result), (indexes_success, indexes_result), (views_success, views_result), # V4-only responses (virtual_ks_success, virtual_ks_result), (virtual_table_success, virtual_table_result), - (virtual_column_success, virtual_column_result) - ) = responses + (virtual_column_success, virtual_column_result), + ) = responses[:11] + + # Unpack triggers response if present (Cassandra/DSE only) + if self._is_not_scylla(): + (triggers_success, triggers_result) = responses[11] # copied from V3 self.keyspaces_result = self._handle_results(ks_success, ks_result, query_msg=queries[0]) self.tables_result = self._handle_results(table_success, table_result, query_msg=queries[1]) self.columns_result = self._handle_results(col_success, col_result, query_msg=queries[2]) - self.triggers_result = self._handle_results(triggers_success, triggers_result, query_msg=queries[6]) self.types_result = self._handle_results(types_success, types_result, query_msg=queries[3]) self.functions_result = self._handle_results(functions_success, functions_result, query_msg=queries[4]) self.aggregates_result = self._handle_results(aggregates_success, aggregates_result, query_msg=queries[5]) - self.indexes_result = self._handle_results(indexes_success, indexes_result, query_msg=queries[7]) - self.views_result = self._handle_results(views_success, views_result, query_msg=queries[8]) + self.indexes_result = self._handle_results(indexes_success, indexes_result, query_msg=queries[6]) + self.views_result = self._handle_results(views_success, views_result, query_msg=queries[7]) + if self._is_not_scylla(): + self.triggers_result = self._handle_results(triggers_success, triggers_result, query_msg=queries[11]) + else: + self.triggers_result = [] # V4-only results # These tables don't exist in some DSE versions reporting 4.X so we can # ignore them if we got an error self.virtual_keyspaces_result = self._handle_results( virtual_ks_success, virtual_ks_result, - expected_failures=(InvalidRequest,), query_msg=queries[9] + expected_failures=(InvalidRequest,), query_msg=queries[8] ) self.virtual_tables_result = self._handle_results( virtual_table_success, virtual_table_result, - expected_failures=(InvalidRequest,), query_msg=queries[10] + expected_failures=(InvalidRequest,), query_msg=queries[9] ) self.virtual_columns_result = self._handle_results( virtual_column_success, virtual_column_result, - expected_failures=(InvalidRequest,), query_msg=queries[11] + expected_failures=(InvalidRequest,), query_msg=queries[10] ) self._aggregate_results() From c3e237862c5e09eec532b0ec5edb013060c94360 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Dec 2025 09:41:02 +0000 Subject: [PATCH 024/200] Fix code quality issues in test_cluster.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix spelling: 'tring' → 'string' in docstring - Remove extra 't' at end of comment - Refactor complex list comprehension for clarity - Use 'is None' instead of '== None' for None comparison Co-authored-by: mykaul <4655593+mykaul@users.noreply.github.com> --- tests/unit/test_cluster.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/unit/test_cluster.py b/tests/unit/test_cluster.py index 49208ac53e..295fe769c5 100644 --- a/tests/unit/test_cluster.py +++ b/tests/unit/test_cluster.py @@ -91,7 +91,10 @@ class ClusterTest(unittest.TestCase): def test_tuple_for_contact_points(self): cluster = Cluster(contact_points=[('localhost', 9045), ('127.0.0.2', 9046), '127.0.0.3'], port=9999) - localhost_addr = set([addr[0] for addr in [t for (_,_,_,_,t) in socket.getaddrinfo("localhost",80)]]) + # Refactored for clarity + addr_info = socket.getaddrinfo("localhost", 80) + sockaddr_tuples = [info[4] for info in addr_info] # info[4] is sockaddr + localhost_addr = set([sockaddr[0] for sockaddr in sockaddr_tuples]) for cp in cluster.endpoints_resolved: if cp.address in localhost_addr: assert cp.port == 9045 @@ -108,7 +111,7 @@ def test_invalid_contact_point_types(self): Cluster(contact_points="not a sequence", protocol_version=4, connect_timeout=1) def test_port_str(self): - """Check port passed as tring is converted and checked properly""" + """Check port passed as string is converted and checked properly""" cluster = Cluster(contact_points=['127.0.0.1'], port='1111') for cp in cluster.endpoints_resolved: if cp.address in ('::1', '127.0.0.1'): @@ -182,7 +185,7 @@ def test_event_delay_timing(self, *_): """ sched = _Scheduler(None) sched.schedule(0, lambda: None) - sched.schedule(0, lambda: None) # pre-473: "TypeError: unorderable types: function() < function()"t + sched.schedule(0, lambda: None) # pre-473: "TypeError: unorderable types: function() < function()" class SessionTest(unittest.TestCase): @@ -292,7 +295,7 @@ def test_default_exec_parameters(self): assert cluster.profile_manager.default.request_timeout == 10.0 assert session.default_consistency_level == ConsistencyLevel.LOCAL_ONE assert cluster.profile_manager.default.consistency_level == ConsistencyLevel.LOCAL_ONE - assert session.default_serial_consistency_level == None + assert session.default_serial_consistency_level is None assert cluster.profile_manager.default.serial_consistency_level == None assert session.row_factory == named_tuple_factory assert cluster.profile_manager.default.row_factory == named_tuple_factory From 8e6c4d4e773a8dffc8bf6515a13cbfef3bb5d0ef Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Dec 2025 09:42:07 +0000 Subject: [PATCH 025/200] Fix additional '== None' comparison for consistency Co-authored-by: mykaul <4655593+mykaul@users.noreply.github.com> --- tests/unit/test_cluster.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_cluster.py b/tests/unit/test_cluster.py index 295fe769c5..4942fd4d69 100644 --- a/tests/unit/test_cluster.py +++ b/tests/unit/test_cluster.py @@ -296,7 +296,7 @@ def test_default_exec_parameters(self): assert session.default_consistency_level == ConsistencyLevel.LOCAL_ONE assert cluster.profile_manager.default.consistency_level == ConsistencyLevel.LOCAL_ONE assert session.default_serial_consistency_level is None - assert cluster.profile_manager.default.serial_consistency_level == None + assert cluster.profile_manager.default.serial_consistency_level is None assert session.row_factory == named_tuple_factory assert cluster.profile_manager.default.row_factory == named_tuple_factory From caa98b60998729b97ad1a375ea1c7723e1728161 Mon Sep 17 00:00:00 2001 From: Avi Kivity Date: Sat, 4 Apr 2026 18:57:39 +0300 Subject: [PATCH 026/200] pool: drop per-query connection log The pool module emits a DEBUG log message when selecting a connection for a query. Emitting a log message for every query is too noisy. Since Python logging lacks a TRACE level, just remove the log. --- cassandra/pool.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/cassandra/pool.py b/cassandra/pool.py index 2da657256f..227e1b5315 100644 --- a/cassandra/pool.py +++ b/cassandra/pool.py @@ -476,12 +476,6 @@ def _get_connection_for_routing_key(self, routing_key=None, keyspace=None, table # optimistic try to connect to it if shard_id is not None: if conn: - log.debug( - "Using connection to shard_id=%i on host %s for routing_key=%s", - shard_id, - self.host, - routing_key - ) if conn.orphaned_threshold_reached and shard_id not in self._connecting: # The connection has met its orphaned stream ID limit # and needs to be replaced. Start opening a connection From e10bf39f0497fd5ca2330c8967de0da582fe4085 Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Sun, 22 Mar 2026 17:07:17 +0200 Subject: [PATCH 027/200] Fix CQL injection in Connection.set_keyspace_blocking and set_keyspace_async Escape double quotes in keyspace names when constructing USE statements to prevent CQL injection. A keyspace name containing '"' would produce malformed or injectable CQL (e.g., USE "foo"bar"). This is the Python equivalent of the vulnerability fixed in the Go driver (gocql#783). The fix escapes '"' as '""' per CQL quoted-identifier rules, matching the existing escape_name() function in cassandra/metadata.py. --- cassandra/connection.py | 6 ++++-- tests/unit/test_connection.py | 37 ++++++++++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/cassandra/connection.py b/cassandra/connection.py index 72b273ec37..c045b36cb3 100644 --- a/cassandra/connection.py +++ b/cassandra/connection.py @@ -1658,7 +1658,8 @@ def set_keyspace_blocking(self, keyspace): if not keyspace or keyspace == self.keyspace: return - query = QueryMessage(query='USE "%s"' % (keyspace,), + from cassandra.metadata import escape_name + query = QueryMessage(query='USE %s' % (escape_name(keyspace),), consistency_level=ConsistencyLevel.ONE) try: result = self.wait_for_response(query) @@ -1712,7 +1713,8 @@ def set_keyspace_async(self, keyspace, callback): callback(self, None) return - query = QueryMessage(query='USE "%s"' % (keyspace,), + from cassandra.metadata import escape_name + query = QueryMessage(query='USE %s' % (escape_name(keyspace),), consistency_level=ConsistencyLevel.ONE) def process_result(result): diff --git a/tests/unit/test_connection.py b/tests/unit/test_connection.py index 6ac63ff761..a67b7e4678 100644 --- a/tests/unit/test_connection.py +++ b/tests/unit/test_connection.py @@ -25,7 +25,8 @@ ConnectionException, ConnectionShutdown, DefaultEndPoint, ShardAwarePortGenerator) from cassandra.marshal import uint8_pack, uint32_pack, int32_pack from cassandra.protocol import (write_stringmultimap, write_int, write_string, - SupportedMessage, ProtocolHandler) + SupportedMessage, ProtocolHandler, ResultMessage, + RESULT_KIND_SET_KEYSPACE) from tests.util import wait_until, assertRegex import pytest @@ -256,6 +257,40 @@ def test_set_keyspace_blocking(self): c.set_keyspace_blocking('ks') assert c.keyspace == 'ks' + def test_set_keyspace_blocking_escapes_quotes(self): + """ + Test that set_keyspace_blocking properly escapes double quotes in + keyspace names to prevent CQL injection. This is the Python equivalent + of the vulnerability fixed in the Go driver: + https://github.com/scylladb/gocql/pull/783 + """ + c = self.make_connection() + c.wait_for_response = Mock(return_value=ResultMessage(kind=RESULT_KIND_SET_KEYSPACE)) + + c.set_keyspace_blocking('my"ks') + query_msg = c.wait_for_response.call_args[0][0] + assert query_msg.query == 'USE "my""ks"', ( + "Double quotes in keyspace name must be escaped as double-double quotes") + + def test_set_keyspace_async_escapes_quotes(self): + """ + Test that set_keyspace_async properly escapes double quotes in + keyspace names to prevent CQL injection. + """ + c = self.make_connection() + c.lock = Lock() + c.in_flight = 0 + c.max_request_id = 100 + c.get_request_id = Mock(return_value=1) + c.send_msg = Mock() + + callback = Mock() + c.set_keyspace_async('my"ks', callback) + + query_msg = c.send_msg.call_args[0][0] + assert query_msg.query == 'USE "my""ks"', ( + "Double quotes in keyspace name must be escaped as double-double quotes") + def test_set_connection_class(self): cluster = Cluster(connection_class='test') assert 'test' == cluster.connection_class From 4502520634b608a3adcf5822333c0a2fcf4e16d9 Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Wed, 25 Mar 2026 17:26:29 +0200 Subject: [PATCH 028/200] Add unit test for Session.set_keyspace double-quote escaping Test that Session.set_keyspace properly escapes double quotes in keyspace names (e.g. 'my"ks' -> USE "my""ks") to prevent CQL injection. Also verifies simple keyspace names are not unnecessarily quoted. Requested in review of PR #758. --- tests/unit/test_cluster.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/unit/test_cluster.py b/tests/unit/test_cluster.py index 4942fd4d69..872d133b28 100644 --- a/tests/unit/test_cluster.py +++ b/tests/unit/test_cluster.py @@ -254,6 +254,33 @@ def test_default_serial_consistency_level_legacy(self, *_): assert f.message.serial_consistency_level == cl_override + + @mock_session_pools + def test_set_keyspace_escapes_quotes(self, *_): + """ + Test that Session.set_keyspace properly escapes double quotes in + keyspace names to prevent CQL injection. + Requested in review of PR #758. + """ + c = Cluster(protocol_version=4) + s = Session(c, [Host("127.0.0.1", SimpleConvictionPolicy, host_id=uuid.uuid4())]) + c.connection_class.initialize_reactor() + + s.execute = Mock() + + s.set_keyspace('my"ks') + query = s.execute.call_args[0][0] + assert query == 'USE "my""ks"', ( + "Double quotes in keyspace name must be escaped as double-double quotes, " + "got: %r" % query) + + # Also verify a simple keyspace name doesn't get unnecessarily quoted + s.execute.reset_mock() + s.set_keyspace('simple_ks') + query = s.execute.call_args[0][0] + assert query == 'USE simple_ks', ( + "Simple keyspace names should not be quoted, got: %r" % query) + class ProtocolVersionTests(unittest.TestCase): def test_protocol_downgrade_test(self): From 117abb2b545b33875bb4607bd1d996ddf686aeab Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Sat, 28 Mar 2026 15:26:56 +0300 Subject: [PATCH 029/200] tests: fix NLB replacement test bootstrap crash due to missing rackdc properties The _bootstrap_node() method in TestFullNodeReplacementThroughNlb calls ccm_cluster.add() without passing data_center or rack. CCM does not infer these from existing nodes, so the new node's cassandra-rackdc.properties file is left with only template comments. Scylla's GossipingPropertyFileSnitch fails to parse the empty file and crashes on startup with 'locator::bad_property_file_error'. Fix by reading data_center/rack from an existing cluster node and passing them explicitly to ccm_cluster.add(). This test was added in PR #706 with a @skip_scylla_version_lt(2026.1.0) decorator and CI runs Scylla 2025.2, so the bug was never caught. --- tests/integration/standard/test_client_routes.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/integration/standard/test_client_routes.py b/tests/integration/standard/test_client_routes.py index a8a3c30f2c..4e328df0c0 100644 --- a/tests/integration/standard/test_client_routes.py +++ b/tests/integration/standard/test_client_routes.py @@ -1178,7 +1178,7 @@ def test_should_survive_full_node_replacement_through_nlb(self): ccm_cluster = get_cluster() for node_id in new_node_ids: - self._bootstrap_node(ccm_cluster, node_id) + self._bootstrap_node(ccm_cluster, node_id, data_center='dc1') expected_total = len(original_node_ids) + len(new_node_ids) self._wait_for_condition( @@ -1283,7 +1283,7 @@ def _query_succeeds(self, session): except Exception: return False - def _bootstrap_node(self, ccm_cluster, node_id): + def _bootstrap_node(self, ccm_cluster, node_id, data_center=None, rack=None): node_type = type(next(iter(ccm_cluster.nodes.values()))) ip = "127.0.0.%d" % node_id node_instance = node_type( @@ -1297,7 +1297,12 @@ def _bootstrap_node(self, ccm_cluster, node_id): remote_debug_port=0, initial_token=None, ) - ccm_cluster.add(node_instance, is_seed=False) + # CCM requires explicit data_center/rack when adding a node so that + # cassandra-rackdc.properties is written correctly. Without this the + # snitch fails to parse the empty properties file and the node crashes + # on startup. + ccm_cluster.add(node_instance, is_seed=False, + data_center=data_center, rack=rack) node_instance.start(wait_for_binary_proto=True, wait_other_notice=True) wait_for_node_socket(node_instance, 120) log.info("Node %d bootstrapped successfully", node_id) From f7e9bc20ca723786557bf852e2203480b6dfc71a Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Tue, 7 Apr 2026 19:09:19 +0300 Subject: [PATCH 030/200] Fix ExponentialBackoffRetryPolicy.__init__ missing self in super() call super(ExponentialBackoffRetryPolicy).__init__() creates an unbound super object, so the parent RetryPolicy.__init__() is never actually invoked. Fix by passing self: super(ExponentialBackoffRetryPolicy, self).__init__(). --- cassandra/policies.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cassandra/policies.py b/cassandra/policies.py index e742708019..ceb5ebdc45 100644 --- a/cassandra/policies.py +++ b/cassandra/policies.py @@ -1158,7 +1158,7 @@ def __init__(self, max_num_retries: float, min_interval: float = 0.1, max_interv self.min_interval = min_interval self.max_num_retries = max_num_retries self.max_interval = max_interval - super(ExponentialBackoffRetryPolicy).__init__(*args, **kwargs) + super(ExponentialBackoffRetryPolicy, self).__init__(*args, **kwargs) def _calculate_backoff(self, attempt: int): delay = min(self.max_interval, self.min_interval * 2 ** attempt) From 62cfd1fcfb6431b3fb3d87b03c5e6374e719e720 Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Tue, 7 Apr 2026 21:46:40 +0300 Subject: [PATCH 031/200] tests: detect stale Cython extensions at test startup Add a pytest_configure hook in tests/conftest.py that compares mtime of each compiled extension against its .py source and warns when the source is newer. This prevents silently testing stale compiled code after editing a Cython-compiled module without rebuilding. The scan iterates over .py source files and checks for the first matching compiled extension per importlib.machinery.EXTENSION_SUFFIXES order, mirroring Python's import machinery and handling both .so (POSIX) and .pyd (Windows) automatically. Also document the rebuild requirement in CONTRIBUTING.rst, using uv commands instead of deprecated setup.py invocations. --- CONTRIBUTING.rst | 13 +++++++++ tests/conftest.py | 67 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 tests/conftest.py diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 8b8fc0e791..82bf21e52f 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -40,6 +40,19 @@ When modifying driver files, rebuilding Cython modules is often necessary. Without caching, each such rebuild may take over a minute. Caching usually brings it down to about 2-3 seconds. +**Important:** After modifying any ``.py`` file under ``cassandra/`` that is +Cython-compiled (such as ``query.py``, ``protocol.py``, ``cluster.py``, etc.), +extensions must be rebuilt before running tests. If you always use ``uv run`` +(e.g. ``uv run pytest``), this is handled automatically via the ``cache-keys`` +configuration in ``pyproject.toml``. If you invoke ``pytest`` directly, you can +rebuild with:: + + uv sync --reinstall-package scylla-driver + +Without rebuilding, Python will load the stale compiled extension (``.so`` / ``.pyd``) +instead of your modified ``.py`` source, and your changes will not actually be tested. +The test suite will emit a warning if it detects this situation. + Building the Docs ================= diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000000..8fd2fc923b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,67 @@ +# Copyright ScyllaDB, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import importlib.machinery +import os +import warnings + +# Directory containing the Cython-compiled driver modules. +_CASSANDRA_DIR = os.path.join(os.path.dirname(__file__), os.pardir, "cassandra") + + +def pytest_configure(config): + """Warn when a compiled Cython extension is older than its .py source. + + Python's import system prefers compiled extensions (.so / .pyd) over pure + Python (.py) files. If a developer edits a .py file without rebuilding + the Cython extensions, the tests + will silently run the *old* compiled code, masking any regressions in the + Python source. + + This hook detects such staleness at test-session startup so the developer + is alerted immediately. + """ + stale = [] + # Iterate over .py sources and, for each module, look for the first + # existing compiled extension in EXTENSION_SUFFIXES order. This mirrors + # how Python's import machinery selects an extension module, and avoids + # globbing patterns like "*{suffix}" that can pick up ABI-tagged + # extensions built for other Python versions. + if os.path.isdir(_CASSANDRA_DIR): + for entry in os.listdir(_CASSANDRA_DIR): + if not entry.endswith(".py"): + continue + module_name, _ = os.path.splitext(entry) + py_path = os.path.join(_CASSANDRA_DIR, entry) + # For this module, find the first extension file Python would load. + for suffix in importlib.machinery.EXTENSION_SUFFIXES: + ext_path = os.path.join(_CASSANDRA_DIR, module_name + suffix) + if not os.path.exists(ext_path): + continue + if os.path.getmtime(py_path) > os.path.getmtime(ext_path): + stale.append((module_name, ext_path, py_path)) + # Only consider the first matching suffix; this is the one + # the import system would actually use. + break + + if stale: + names = ", ".join(m for m, _, _ in stale) + warnings.warn( + f"Stale Cython extension(s) detected: {names}. " + f"The .py source is newer than the compiled extension — tests " + f"will run the OLD compiled code, not your latest changes. " + f"Rebuild with: uv sync --reinstall-package scylla-driver\n" + f"Or use 'uv run pytest' which handles rebuilds automatically.", + stacklevel=1, + ) From 442074c1743378d7cbc631be7fd137f636d7373f Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Tue, 7 Apr 2026 21:46:46 +0300 Subject: [PATCH 032/200] docs: replace direct setup.py invocations with pip in installation guide Replace all 'python setup.py install' instructions with 'pip install .' or 'pip install scylla-driver' equivalents. Replace setup.py-specific command-line flags (--no-cython, --no-extensions, etc.) with their environment variable equivalents (CASS_DRIVER_NO_CYTHON, CASS_DRIVER_NO_EXTENSIONS, CASS_DRIVER_NO_LIBEV). Remove deprecated pip --install-option usage. --- docs/installation.rst | 33 ++++++++++++--------------------- 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/docs/installation.rst b/docs/installation.rst index 7b4823b832..fbb9ac4043 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -62,9 +62,6 @@ threads used to build the driver and any C extensions: .. code-block:: bash - $ # installing from source - $ CASS_DRIVER_BUILD_CONCURRENCY=8 python setup.py install - $ # installing from pip $ CASS_DRIVER_BUILD_CONCURRENCY=8 pip install scylla-driver Note that by default (when CASS_DRIVER_BUILD_CONCURRENCY is not specified), concurrency will be equal to the number of @@ -108,7 +105,7 @@ installed. You can find the list of dependencies in Once the dependencies are installed, simply run:: - python setup.py install + pip install . (*Optional*) Non-python Dependencies @@ -122,9 +119,9 @@ for token-aware routing with the ``Murmur3Partitioner``, `libev `_ event loop integration, and Cython optimized extensions. -When installing manually through setup.py, you can disable both with -the ``--no-extensions`` option, or selectively disable them with -with ``--no-murmur3``, ``--no-libev``, or ``--no-cython``. +Extensions can be selectively disabled using environment variables: +``CASS_DRIVER_NO_EXTENSIONS=1`` (disable all), ``CASS_DRIVER_NO_CYTHON=1``, +or ``CASS_DRIVER_NO_LIBEV=1``. To compile the extensions, ensure that GCC and the Python headers are available. @@ -149,31 +146,25 @@ This is not a hard requirement, but is engaged by default to build extensions of pure Python implementation. This is a costly build phase, especially in clean environments where the Cython compiler must be built -This build phase can be avoided using the build switch, or an environment variable:: +This build phase can be avoided using an environment variable:: - python setup.py install --no-cython + CASS_DRIVER_NO_CYTHON=1 pip install scylla-driver -Alternatively, an environment variable can be used to switch this option regardless of +Alternatively, the environment variable can be used to switch this option regardless of context:: CASS_DRIVER_NO_CYTHON=1 - or, to disable all extensions: CASS_DRIVER_NO_EXTENSIONS=1 -This method is required when using pip, which provides no other way of injecting user options in a single command:: - - CASS_DRIVER_NO_CYTHON=1 pip install scylla-driver - CASS_DRIVER_NO_CYTHON=1 sudo -E pip install ~/python-driver - -The environment variable is the preferred option because it spans all invocations of setup.py, and will +These environment variables are the preferred option, and will prevent Cython from being materialized as a setup requirement. -If your sudo configuration does not allow SETENV, you must push the option flag down via pip. However, pip -applies these options to all dependencies (which break on the custom flag). Therefore, you must first install -dependencies, then use install-option:: +If your sudo configuration does not allow SETENV, you must first install +dependencies, then install the driver:: sudo pip install futures - sudo pip install --install-option="--no-cython" + sudo CASS_DRIVER_NO_CYTHON=1 pip install scylla-driver Supported Event Loops @@ -205,7 +196,7 @@ install libev using any Windows package manager. For example, to install using $ vcpkg install libev If successful, you should be able to build and install the extension -(just using ``setup.py build`` or ``setup.py install``) and then use +(just using ``pip install .``) and then use the libev event loop by doing the following: .. code-block:: python From ee98fd0413994ee31345db7db1e5f2b608418a74 Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Fri, 27 Mar 2026 11:14:12 +0300 Subject: [PATCH 033/200] tests: fix incorrect retry count in execute_with_long_wait_retry error message The error message said 'Failed after 100 attempts' but the retry limit is 10 (while tries < 10). This was a copy-paste error from execute_until_pass() which does retry 100 times. --- tests/integration/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py index 2015e0663f..286561c291 100644 --- a/tests/integration/__init__.py +++ b/tests/integration/__init__.py @@ -600,7 +600,7 @@ def execute_with_long_wait_retry(session, query, timeout=30): del tb tries += 1 - raise RuntimeError("Failed to execute query after 100 attempts: {0}".format(query)) + raise RuntimeError("Failed to execute query after 10 attempts: {0}".format(query)) def execute_with_retry_tolerant(session, query, retry_exceptions, escape_exception): From f7890b912c7cebbbc8f4e16368bdb8091c96553b Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Fri, 27 Mar 2026 11:20:03 +0300 Subject: [PATCH 034/200] tests: standardize test_cluster.py to --smp 2 Change test_cluster.py from --smp 1 to --smp 2 to match the standard configuration used by other test files. This enables cluster topology consolidation in a follow-up commit. --- tests/integration/standard/test_cluster.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/standard/test_cluster.py b/tests/integration/standard/test_cluster.py index aab4131739..6db9657932 100644 --- a/tests/integration/standard/test_cluster.py +++ b/tests/integration/standard/test_cluster.py @@ -52,7 +52,7 @@ def setup_module(): - os.environ['SCYLLA_EXT_OPTS'] = "--smp 1" + os.environ['SCYLLA_EXT_OPTS'] = "--smp 2" use_cluster("cluster_tests", [3], start=True, workloads=None) warnings.simplefilter("always") From aa0043a3add6829b8b6d5022be7357ef4b85bbfc Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Fri, 27 Mar 2026 11:23:18 +0300 Subject: [PATCH 035/200] tests: consolidate cluster topologies to reduce cluster teardown/setup Merge cluster names for test files with identical configurations: - test_shard_aware.py: 'shard_aware' -> 'cluster_tests' (same --smp 2, 3 nodes as test_cluster.py) - test_client_routes.py: 'test_client_routes' -> 'shared_aware' (same --smp 2 --memory 2048M, 3 nodes as test_use_keyspace.py) This allows the CCM cluster to be reused when these tests run sequentially, avoiding a full cluster teardown and restart. Also update conftest.py cleanup list to include 'cluster_tests' and 'test_client_routes_replacement' which were previously missing. --- tests/integration/conftest.py | 2 +- tests/integration/standard/test_client_routes.py | 2 +- tests/integration/standard/test_shard_aware.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index a682bcb608..5db8026675 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -17,7 +17,7 @@ def cleanup_clusters(): if not os.environ.get('DISABLE_CLUSTER_CLEANUP'): for cluster_name in [CLUSTER_NAME, SINGLE_NODE_CLUSTER_NAME, MULTIDC_CLUSTER_NAME, - 'shared_aware', 'sni_proxy', 'test_ip_change']: + 'cluster_tests', 'shared_aware', 'sni_proxy', 'test_ip_change', 'test_client_routes_replacement']: try: cluster = CCMClusterFactory.load(ccm_path, cluster_name) logging.debug("Using external CCM cluster {0}".format(cluster.name)) diff --git a/tests/integration/standard/test_client_routes.py b/tests/integration/standard/test_client_routes.py index 4e328df0c0..a799073e25 100644 --- a/tests/integration/standard/test_client_routes.py +++ b/tests/integration/standard/test_client_routes.py @@ -521,7 +521,7 @@ def assert_routes_direct(test, cluster, expected_node_ids, direct_port=9042): def setup_module(): os.environ['SCYLLA_EXT_OPTS'] = "--smp 2 --memory 2048M" - use_cluster('test_client_routes', [3], start=True) + use_cluster('shared_aware', [3], start=True) @skip_scylla_version_lt(reason='scylladb/scylladb#26992 - system.client_routes is not yet supported', scylla_version="2026.1.0") diff --git a/tests/integration/standard/test_shard_aware.py b/tests/integration/standard/test_shard_aware.py index 2d764d681e..0fdb9ed08d 100644 --- a/tests/integration/standard/test_shard_aware.py +++ b/tests/integration/standard/test_shard_aware.py @@ -33,7 +33,7 @@ def setup_module(): os.environ['SCYLLA_EXT_OPTS'] = "--smp 2" - use_cluster('shard_aware', [3], start=True) + use_cluster('cluster_tests', [3], start=True) class TestShardAwareIntegration(unittest.TestCase): From dd15509a5c0463614eba7b2704e006edf5f3fc68 Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Fri, 27 Mar 2026 11:24:22 +0300 Subject: [PATCH 036/200] tests: add test ordering by cluster topology to minimize restarts Add pytest_collection_modifyitems hook that sorts test modules by their cluster configuration group. This ensures tests sharing the same CCM cluster (same name, same node count, same ext opts) run adjacently, avoiding unnecessary cluster teardown/restart cycles between modules. Groups: default singledc -> cluster_tests -> shared_aware -> single_node -> destructive/special clusters. --- tests/integration/standard/conftest.py | 65 ++++++++++++++++++- .../standard/test_rate_limit_exceeded.py | 4 +- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/tests/integration/standard/conftest.py b/tests/integration/standard/conftest.py index 6028c2a06d..3adaf371b0 100644 --- a/tests/integration/standard/conftest.py +++ b/tests/integration/standard/conftest.py @@ -1,6 +1,69 @@ import pytest import logging +# Cluster topology groups for test ordering. +# Tests are sorted so that modules sharing the same CCM cluster run +# together, minimising expensive cluster teardown/restart cycles. +# Lower number = runs first. Modules not listed get a high default. +_MODULE_CLUSTER_ORDER = { + # Group 0: default 3-node singledc (CLUSTER_NAME = 'test_cluster') + "test_metadata": 0, + "test_policies": 0, + "test_control_connection": 0, + "test_routing": 0, + "test_prepared_statements": 0, + "test_metrics": 0, + "test_connection": 0, + "test_concurrent": 0, + "test_custom_payload": 0, + "test_query_paging": 0, + "test_single_interface": 0, + "test_rate_limit_exceeded": 0, + # Group 1: 'cluster_tests' (--smp 2, 3 nodes) + "test_cluster": 1, + "test_shard_aware": 1, + # Group 2: 'shared_aware' (--smp 2 --memory 2048M, 3 nodes) + "test_use_keyspace": 2, + "test_client_routes": 2, + # Group 3: single-node cluster + "test_types": 3, + "test_cython_protocol_handlers": 3, + "test_custom_protocol_handler": 3, + "test_row_factories": 3, + "test_udts": 3, + "test_client_warnings": 3, + "test_application_info": 3, + # Group 4: destructive / special clusters (run last) + "test_ip_change": 4, + "test_authentication": 4, + "test_authentication_misconfiguration": 4, + "test_custom_cluster": 4, + "test_query": 4, + # Group 5: tablets (destructive — decommissions a node) + "test_tablets": 5, + # Group 6: schema change + node kill (destructive — kills node2) + "test_concurrent_schema_change_and_node_kill": 6, + # Group 7: multi-dc (7 nodes — most expensive to create) + "test_rack_aware_policy": 7, +} + + +def pytest_collection_modifyitems(items): + """Sort tests so modules with the same cluster topology are adjacent. + + Uses the original collection index as tie-breaker so that the + definition order inside each file is preserved (important for tests + that depend on running order, e.g. destructive tablet tests). + """ + orig_order = {id(item): idx for idx, item in enumerate(items)} + + def _sort_key(item): + module_name = item.module.__name__.rsplit(".", 1)[-1] + return (_MODULE_CLUSTER_ORDER.get(module_name, 99), item.fspath, orig_order[id(item)]) + + items[:] = sorted(items, key=_sort_key) + + # from https://github.com/streamlit/streamlit/pull/5047/files def pytest_sessionfinish(): # We're not waiting for scriptrunner threads to cleanly close before ending the PyTest, @@ -10,4 +73,4 @@ def pytest_sessionfinish(): # * https://github.com/pytest-dev/pytest/issues/5282 # To prevent the exception from being raised on pytest_sessionfinish # we disable exception raising in logging module - logging.raiseExceptions = False \ No newline at end of file + logging.raiseExceptions = False diff --git a/tests/integration/standard/test_rate_limit_exceeded.py b/tests/integration/standard/test_rate_limit_exceeded.py index 211f0c9930..ea7dfc7d61 100644 --- a/tests/integration/standard/test_rate_limit_exceeded.py +++ b/tests/integration/standard/test_rate_limit_exceeded.py @@ -4,13 +4,13 @@ from cassandra.cluster import Cluster from cassandra.policies import ConstantReconnectionPolicy, RoundRobinPolicy, TokenAwarePolicy -from tests.integration import PROTOCOL_VERSION, use_cluster +from tests.integration import PROTOCOL_VERSION, use_singledc import pytest LOGGER = logging.getLogger(__name__) def setup_module(): - use_cluster('rate_limit', [3], start=True) + use_singledc() class TestRateLimitExceededException(unittest.TestCase): @classmethod From b038f4fb6957e13894f7a5da5c43f741c99f8097 Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Fri, 27 Mar 2026 11:25:39 +0300 Subject: [PATCH 037/200] tests: switch 6 test files from 3-node to single-node cluster These test files don't require multiple nodes for their test logic (they test data types, protocol handlers, row factories, UDTs, and client warnings). Using a single node reduces resource usage and cluster startup time. Files switched from use_singledc() to use_single_node(): - test_types.py - test_cython_protocol_handlers.py - test_custom_protocol_handler.py - test_row_factories.py - test_udts.py - test_client_warnings.py --- tests/integration/standard/test_client_warnings.py | 4 ++-- tests/integration/standard/test_custom_protocol_handler.py | 4 ++-- tests/integration/standard/test_cython_protocol_handlers.py | 4 ++-- tests/integration/standard/test_row_factories.py | 4 ++-- tests/integration/standard/test_types.py | 4 ++-- tests/integration/standard/test_udts.py | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/integration/standard/test_client_warnings.py b/tests/integration/standard/test_client_warnings.py index 781b5b7860..c18fa8cb1f 100644 --- a/tests/integration/standard/test_client_warnings.py +++ b/tests/integration/standard/test_client_warnings.py @@ -17,13 +17,13 @@ from cassandra.query import BatchStatement -from tests.integration import (use_singledc, PROTOCOL_VERSION, local, TestCluster, +from tests.integration import (use_single_node, PROTOCOL_VERSION, local, TestCluster, requires_custom_payload, xfail_scylla) from tests.util import assertRegex, assertDictEqual def setup_module(): - use_singledc() + use_single_node() @xfail_scylla('scylladb/scylladb#10196 - scylla does not report warnings') class ClientWarningTests(unittest.TestCase): diff --git a/tests/integration/standard/test_custom_protocol_handler.py b/tests/integration/standard/test_custom_protocol_handler.py index 239f7e7336..e123f2050e 100644 --- a/tests/integration/standard/test_custom_protocol_handler.py +++ b/tests/integration/standard/test_custom_protocol_handler.py @@ -20,7 +20,7 @@ ContinuousPagingOptions, NoHostAvailable) from cassandra import ProtocolVersion, ConsistencyLevel -from tests.integration import use_singledc, drop_keyspace_shutdown_cluster, \ +from tests.integration import use_single_node, drop_keyspace_shutdown_cluster, \ greaterthanorequalcass30, execute_with_long_wait_retry, greaterthanorequalcass3_10, \ TestCluster, greaterthanorequalcass40 from tests.integration.datatype_utils import update_datatypes, PRIMITIVE_DATATYPES @@ -32,7 +32,7 @@ def setup_module(): - use_singledc() + use_single_node() update_datatypes() diff --git a/tests/integration/standard/test_cython_protocol_handlers.py b/tests/integration/standard/test_cython_protocol_handlers.py index f44d613c64..9c94b2ac77 100644 --- a/tests/integration/standard/test_cython_protocol_handlers.py +++ b/tests/integration/standard/test_cython_protocol_handlers.py @@ -12,7 +12,7 @@ from cassandra.protocol import ProtocolHandler, LazyProtocolHandler, NumpyProtocolHandler from cassandra.query import tuple_factory from tests import VERIFY_CYTHON -from tests.integration import use_singledc, notprotocolv1, \ +from tests.integration import use_single_node, notprotocolv1, \ drop_keyspace_shutdown_cluster, BasicSharedKeyspaceUnitTestCase, greaterthancass21, TestCluster from tests.integration.datatype_utils import update_datatypes from tests.integration.standard.utils import ( @@ -21,7 +21,7 @@ def setup_module(): - use_singledc() + use_single_node() update_datatypes() diff --git a/tests/integration/standard/test_row_factories.py b/tests/integration/standard/test_row_factories.py index 187f35704a..818f11c061 100644 --- a/tests/integration/standard/test_row_factories.py +++ b/tests/integration/standard/test_row_factories.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from tests.integration import get_server_versions, use_singledc, \ +from tests.integration import get_server_versions, use_single_node, \ BasicSharedKeyspaceUnitTestCaseWFunctionTable, BasicSharedKeyspaceUnitTestCase, execute_until_pass, TestCluster import unittest @@ -24,7 +24,7 @@ def setup_module(): - use_singledc() + use_single_node() class NameTupleFactory(BasicSharedKeyspaceUnitTestCase): diff --git a/tests/integration/standard/test_types.py b/tests/integration/standard/test_types.py index 1d66ce1ed9..559a6b3da0 100644 --- a/tests/integration/standard/test_types.py +++ b/tests/integration/standard/test_types.py @@ -38,7 +38,7 @@ from tests.unit.cython.utils import cythontest from tests.util import assertEqual -from tests.integration import use_singledc, execute_until_pass, notprotocolv1, \ +from tests.integration import use_single_node, execute_until_pass, notprotocolv1, \ BasicSharedKeyspaceUnitTestCase, greaterthancass21, lessthancass30, \ greaterthanorequalcass3_10, TestCluster, requires_composite_type, \ requires_vector_type @@ -48,7 +48,7 @@ def setup_module(): - use_singledc() + use_single_node() update_datatypes() diff --git a/tests/integration/standard/test_udts.py b/tests/integration/standard/test_udts.py index dd696ea0e9..e608a9610b 100644 --- a/tests/integration/standard/test_udts.py +++ b/tests/integration/standard/test_udts.py @@ -21,7 +21,7 @@ from cassandra.query import dict_factory from cassandra.util import OrderedMap -from tests.integration import use_singledc, execute_until_pass, \ +from tests.integration import use_single_node, execute_until_pass, \ BasicSegregatedKeyspaceUnitTestCase, greaterthancass20, lessthancass30, greaterthanorequalcass36, TestCluster from tests.integration.datatype_utils import update_datatypes, PRIMITIVE_DATATYPES, PRIMITIVE_DATATYPES_KEYS, \ COLLECTION_TYPES, get_sample, get_collection_sample @@ -32,7 +32,7 @@ def setup_module(): - use_singledc() + use_single_node() update_datatypes() From ca0758df60154d729e50305a03a150fe3d02af63 Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Fri, 27 Mar 2026 11:27:50 +0300 Subject: [PATCH 038/200] tests: reduce cluster churn in LoadBalancingPolicyTests Move remove_cluster() from setUp (which ran before every test) to only the destructive test methods that actually need a fresh cluster. Read-only tests (test_token_aware_is_used_by_default, test_token_aware_composite_key, test_token_aware_with_local_table, test_dc_aware_roundrobin_two_dcs, test_dc_aware_roundrobin_two_dcs_2) can now reuse an existing cluster, avoiding 5 unnecessary cluster teardown/startup cycles. --- .../integration/long/test_loadbalancingpolicies.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/integration/long/test_loadbalancingpolicies.py b/tests/integration/long/test_loadbalancingpolicies.py index fd8edde14c..072786dc23 100644 --- a/tests/integration/long/test_loadbalancingpolicies.py +++ b/tests/integration/long/test_loadbalancingpolicies.py @@ -45,7 +45,6 @@ class LoadBalancingPolicyTests(unittest.TestCase): def setUp(self): - remove_cluster() # clear ahead of test so it doesn't use one left in unknown state self.coordinator_stats = CoordinatorStats() self.prepared = None self.probe_cluster = None @@ -191,6 +190,7 @@ def test_token_aware_is_used_by_default(self): assert isinstance(cluster.profile_manager.default.load_balancing_policy, DCAwareRoundRobinPolicy) def test_roundrobin(self): + remove_cluster() use_singledc() keyspace = 'test_roundrobin' cluster, session = self._cluster_session_with_lbp(RoundRobinPolicy()) @@ -228,6 +228,7 @@ def test_roundrobin(self): self.coordinator_stats.assert_query_count_equals(3, 6) def test_roundrobin_two_dcs(self): + remove_cluster() use_multidc([2, 2]) keyspace = 'test_roundrobin_two_dcs' cluster, session = self._cluster_session_with_lbp(RoundRobinPolicy()) @@ -261,6 +262,7 @@ def test_roundrobin_two_dcs(self): self.coordinator_stats.assert_query_count_equals(5, 3) def test_roundrobin_two_dcs_2(self): + remove_cluster() use_multidc([2, 2]) keyspace = 'test_roundrobin_two_dcs_2' cluster, session = self._cluster_session_with_lbp(RoundRobinPolicy()) @@ -294,6 +296,7 @@ def test_roundrobin_two_dcs_2(self): self.coordinator_stats.assert_query_count_equals(5, 3) def test_dc_aware_roundrobin_two_dcs(self): + remove_cluster() use_multidc([3, 2]) keyspace = 'test_dc_aware_roundrobin_two_dcs' cluster, session = self._cluster_session_with_lbp(DCAwareRoundRobinPolicy('dc1')) @@ -311,6 +314,7 @@ def test_dc_aware_roundrobin_two_dcs(self): self.coordinator_stats.assert_query_count_equals(5, 0) def test_dc_aware_roundrobin_two_dcs_2(self): + remove_cluster() use_multidc([3, 2]) keyspace = 'test_dc_aware_roundrobin_two_dcs_2' cluster, session = self._cluster_session_with_lbp(DCAwareRoundRobinPolicy('dc2')) @@ -328,6 +332,7 @@ def test_dc_aware_roundrobin_two_dcs_2(self): self.coordinator_stats.assert_query_count_equals(5, 6) def test_dc_aware_roundrobin_one_remote_host(self): + remove_cluster() use_multidc([2, 2]) keyspace = 'test_dc_aware_roundrobin_one_remote_host' cluster, session = self._cluster_session_with_lbp(DCAwareRoundRobinPolicy('dc2', used_hosts_per_remote_dc=1)) @@ -410,6 +415,7 @@ def test_token_aware_prepared(self): self.token_aware(keyspace, True) def token_aware(self, keyspace, use_prepared=False): + remove_cluster() use_singledc() cluster, session = self._cluster_session_with_lbp(TokenAwarePolicy(RoundRobinPolicy())) self.addCleanup(cluster.shutdown) @@ -505,6 +511,7 @@ def test_token_aware_composite_key(self): assert results[0].i def test_token_aware_with_rf_2(self, use_prepared=False): + remove_cluster() use_singledc() keyspace = 'test_token_aware_with_rf_2' cluster, session = self._cluster_session_with_lbp(TokenAwarePolicy(RoundRobinPolicy())) @@ -617,6 +624,7 @@ def test_token_aware_with_transient_replication(self): @test_category policy """ + remove_cluster() # We can test this with a single dc when CASSANDRA-15670 is fixed use_multidc([3, 3]) @@ -647,6 +655,7 @@ def test_token_aware_with_transient_replication(self): def _set_up_shuffle_test(self, keyspace, replication_factor): + remove_cluster() use_singledc() cluster, session = self._cluster_session_with_lbp( TokenAwarePolicy(RoundRobinPolicy(), shuffle_replicas=True) @@ -678,6 +687,7 @@ def _check_query_order_changes(self, session, keyspace): self.coordinator_stats.reset_counts() def test_white_list(self): + remove_cluster() use_singledc() keyspace = 'test_white_list' @@ -723,6 +733,7 @@ def test_black_list_with_host_filter_policy(self): @test_category policy """ + remove_cluster() use_singledc() keyspace = 'test_black_list_with_hfp' ignored_address = (IP_FORMAT % 2) From 226bd109439633f86b66c4c0a7e708fbfa537645 Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Fri, 27 Mar 2026 12:45:54 +0300 Subject: [PATCH 039/200] tests: fix auth warning assertion for --smp 2 compatibility The test_can_connect_with_sslauth test asserted exact equality between auth warning count and ReadyMessage count. With --smp 2, shard-aware connections produce additional ReadyMessages, breaking the equality. Drop the exact equality check and assert a lower bound of >= 3 (one per node connection in a 3-node cluster). The control connection and shard-aware connections may produce additional warnings, so the actual count varies between runs. --- tests/integration/standard/test_cluster.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/integration/standard/test_cluster.py b/tests/integration/standard/test_cluster.py index 6db9657932..3dd08aae07 100644 --- a/tests/integration/standard/test_cluster.py +++ b/tests/integration/standard/test_cluster.py @@ -720,10 +720,13 @@ def _warning_are_issued_when_auth(self, auth_provider): session = cluster.connect() assert session.execute("SELECT * from system.local WHERE key='local'") is not None - # Three conenctions to nodes plus the control connection + # Verify that auth warnings are issued for connections where + # auth is configured but the server does not send a challenge. + # At minimum one warning per node connection (3 for a 3-node + # cluster). The control connection and shard-aware connections + # may add more, so we only assert a lower bound. auth_warning = mock_handler.get_message_count('warning', "An authentication challenge was not sent") - assert auth_warning >= 4 - assert auth_warning == mock_handler.get_message_count("debug", "Got ReadyMessage on new connection") + assert auth_warning >= 3 def _wait_for_all_shard_connections(self, cluster, timeout=30): """Wait until all shard-aware connections are fully established.""" From 4eb1bfac72a1a4ecc9f303b3dc348e60584a1139 Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Sat, 28 Mar 2026 15:29:40 +0300 Subject: [PATCH 040/200] tests: shorten cluster name to avoid Unix socket path limit The cluster name 'test_concurrent_schema_change_and_node_kill' (43 chars) causes the maintenance socket path to exceed the 107-byte sun_path limit on Linux when the working directory is deep enough. Shorten to 'test_schema_kill' to stay well within the limit for all environments. --- .../standard/test_concurrent_schema_change_and_node_kill.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/standard/test_concurrent_schema_change_and_node_kill.py b/tests/integration/standard/test_concurrent_schema_change_and_node_kill.py index aeda381c0d..910dcaa9fe 100644 --- a/tests/integration/standard/test_concurrent_schema_change_and_node_kill.py +++ b/tests/integration/standard/test_concurrent_schema_change_and_node_kill.py @@ -8,7 +8,7 @@ def setup_module(): - use_cluster('test_concurrent_schema_change_and_node_kill', [3], start=True) + use_cluster('test_schema_kill', [3], start=True) @local class TestConcurrentSchemaChangeAndNodeKill(unittest.TestCase): From f3ec8817a33acd9b3d907a181f509b271bd7d7f6 Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Sat, 28 Mar 2026 20:35:48 +0300 Subject: [PATCH 041/200] tests: save/restore SCYLLA_EXT_OPTS to prevent env variable leak Several test modules set SCYLLA_EXT_OPTS in setup_module() but never restore it in teardown_module(). When tests are reordered to share clusters, stale values can leak into subsequent modules and cause misconfigured clusters. Save the original value before overwriting and restore it on teardown in: - test_cluster.py - test_shard_aware.py - test_use_keyspace.py - test_ip_change.py - test_client_routes.py (module-level and TestFullNodeReplacementThroughNlb) - test_authentication.py --- .../integration/standard/test_authentication.py | 8 ++++++++ .../integration/standard/test_client_routes.py | 17 +++++++++++++++++ tests/integration/standard/test_cluster.py | 12 ++++++++++++ tests/integration/standard/test_ip_change.py | 11 +++++++++++ tests/integration/standard/test_shard_aware.py | 12 ++++++++++++ tests/integration/standard/test_use_keyspace.py | 11 +++++++++++ 6 files changed, 71 insertions(+) diff --git a/tests/integration/standard/test_authentication.py b/tests/integration/standard/test_authentication.py index 502fdf8993..f172707fff 100644 --- a/tests/integration/standard/test_authentication.py +++ b/tests/integration/standard/test_authentication.py @@ -34,8 +34,12 @@ #This can be tested for remote hosts, but the cluster has to be configured accordingly #@local +_saved_scylla_ext_opts = None + def setup_module(): + global _saved_scylla_ext_opts + _saved_scylla_ext_opts = os.environ.get('SCYLLA_EXT_OPTS') if CASSANDRA_IP.startswith("127.0.0.") and not USE_CASS_EXTERNAL: use_singledc(start=False) ccm_cluster = get_cluster() @@ -71,6 +75,10 @@ def _check_auth_ready(): def teardown_module(): remove_cluster() # this test messes with config + if _saved_scylla_ext_opts is None: + os.environ.pop('SCYLLA_EXT_OPTS', None) + else: + os.environ['SCYLLA_EXT_OPTS'] = _saved_scylla_ext_opts class AuthenticationTests(unittest.TestCase): diff --git a/tests/integration/standard/test_client_routes.py b/tests/integration/standard/test_client_routes.py index a799073e25..9471c95867 100644 --- a/tests/integration/standard/test_client_routes.py +++ b/tests/integration/standard/test_client_routes.py @@ -519,10 +519,22 @@ def assert_routes_direct(test, cluster, expected_node_ids, direct_port=9042): ) +_saved_scylla_ext_opts = None + + def setup_module(): + global _saved_scylla_ext_opts + _saved_scylla_ext_opts = os.environ.get('SCYLLA_EXT_OPTS') os.environ['SCYLLA_EXT_OPTS'] = "--smp 2 --memory 2048M" use_cluster('shared_aware', [3], start=True) + +def teardown_module(): + if _saved_scylla_ext_opts is None: + os.environ.pop('SCYLLA_EXT_OPTS', None) + else: + os.environ['SCYLLA_EXT_OPTS'] = _saved_scylla_ext_opts + @skip_scylla_version_lt(reason='scylladb/scylladb#26992 - system.client_routes is not yet supported', scylla_version="2026.1.0") class TestGetHostPortMapping(unittest.TestCase): @@ -1116,6 +1128,7 @@ class TestFullNodeReplacementThroughNlb(unittest.TestCase): @classmethod def setUpClass(cls): + cls._saved_scylla_ext_opts = os.environ.get('SCYLLA_EXT_OPTS') os.environ['SCYLLA_EXT_OPTS'] = "--smp 2 --memory 2048M" use_cluster('test_client_routes_replacement', [3], start=True) @@ -1133,6 +1146,10 @@ def setUpClass(cls): @classmethod def tearDownClass(cls): cls.direct_cluster.shutdown() + if cls._saved_scylla_ext_opts is None: + os.environ.pop('SCYLLA_EXT_OPTS', None) + else: + os.environ['SCYLLA_EXT_OPTS'] = cls._saved_scylla_ext_opts def test_should_survive_full_node_replacement_through_nlb(self): """ diff --git a/tests/integration/standard/test_cluster.py b/tests/integration/standard/test_cluster.py index 3dd08aae07..08b823d716 100644 --- a/tests/integration/standard/test_cluster.py +++ b/tests/integration/standard/test_cluster.py @@ -51,12 +51,24 @@ log = logging.getLogger(__name__) +_saved_scylla_ext_opts = None + + def setup_module(): + global _saved_scylla_ext_opts + _saved_scylla_ext_opts = os.environ.get('SCYLLA_EXT_OPTS') os.environ['SCYLLA_EXT_OPTS'] = "--smp 2" use_cluster("cluster_tests", [3], start=True, workloads=None) warnings.simplefilter("always") +def teardown_module(): + if _saved_scylla_ext_opts is None: + os.environ.pop('SCYLLA_EXT_OPTS', None) + else: + os.environ['SCYLLA_EXT_OPTS'] = _saved_scylla_ext_opts + + class IgnoredHostPolicy(RoundRobinPolicy): def __init__(self, ignored_hosts): diff --git a/tests/integration/standard/test_ip_change.py b/tests/integration/standard/test_ip_change.py index 6d23d30e04..53debfa1f5 100644 --- a/tests/integration/standard/test_ip_change.py +++ b/tests/integration/standard/test_ip_change.py @@ -10,11 +10,22 @@ LOGGER = logging.getLogger(__name__) +_saved_scylla_ext_opts = None + def setup_module(): + global _saved_scylla_ext_opts + _saved_scylla_ext_opts = os.environ.get('SCYLLA_EXT_OPTS') os.environ['SCYLLA_EXT_OPTS'] = "--smp 2 --memory 2048M" use_cluster('test_ip_change', [3], start=True) + +def teardown_module(): + if _saved_scylla_ext_opts is None: + os.environ.pop('SCYLLA_EXT_OPTS', None) + else: + os.environ['SCYLLA_EXT_OPTS'] = _saved_scylla_ext_opts + @local class TestIpAddressChange(unittest.TestCase): @classmethod diff --git a/tests/integration/standard/test_shard_aware.py b/tests/integration/standard/test_shard_aware.py index 0fdb9ed08d..d1f3e27abd 100644 --- a/tests/integration/standard/test_shard_aware.py +++ b/tests/integration/standard/test_shard_aware.py @@ -31,11 +31,23 @@ LOGGER = logging.getLogger(__name__) +_saved_scylla_ext_opts = None + + def setup_module(): + global _saved_scylla_ext_opts + _saved_scylla_ext_opts = os.environ.get('SCYLLA_EXT_OPTS') os.environ['SCYLLA_EXT_OPTS'] = "--smp 2" use_cluster('cluster_tests', [3], start=True) +def teardown_module(): + if _saved_scylla_ext_opts is None: + os.environ.pop('SCYLLA_EXT_OPTS', None) + else: + os.environ['SCYLLA_EXT_OPTS'] = _saved_scylla_ext_opts + + class TestShardAwareIntegration(unittest.TestCase): @classmethod def setup_class(cls): diff --git a/tests/integration/standard/test_use_keyspace.py b/tests/integration/standard/test_use_keyspace.py index 25e954b956..80e7cfe5f3 100644 --- a/tests/integration/standard/test_use_keyspace.py +++ b/tests/integration/standard/test_use_keyspace.py @@ -14,12 +14,23 @@ LOGGER = logging.getLogger(__name__) +_saved_scylla_ext_opts = None + def setup_module(): + global _saved_scylla_ext_opts + _saved_scylla_ext_opts = os.environ.get('SCYLLA_EXT_OPTS') os.environ['SCYLLA_EXT_OPTS'] = "--smp 2 --memory 2048M" use_cluster('shared_aware', [3], start=True) +def teardown_module(): + if _saved_scylla_ext_opts is None: + os.environ.pop('SCYLLA_EXT_OPTS', None) + else: + os.environ['SCYLLA_EXT_OPTS'] = _saved_scylla_ext_opts + + @local class TestUseKeyspace(unittest.TestCase): @classmethod From 92aa6690724969597cc6a79f70f1a1cb70c550ef Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Sat, 28 Mar 2026 20:37:15 +0300 Subject: [PATCH 042/200] ci: cache Scylla download across CI matrix jobs Add an actions/cache step for ~/.ccm/repository keyed on the Scylla version and runner OS. On cache hit the 'Download Scylla' step becomes a near-instant no-op. On miss (or version bump) CCM re-downloads as before, so there is no regression risk. --- .github/workflows/integration-tests.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 048dbd1352..3c75a33603 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -77,6 +77,12 @@ jobs: - name: Build driver run: uv sync + - name: Cache Scylla download + uses: actions/cache@v4 + with: + path: ~/.ccm/repository + key: scylla-${{ env.SCYLLA_VERSION }}-${{ runner.os }} + # This is to get honest accounting of test time vs download time vs build time. # Not strictly necessary for running tests. - name: Download Scylla From 56498e3aafc7c90f9d5b6668c8f2c74c033a42ca Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Sun, 29 Mar 2026 13:34:52 +0300 Subject: [PATCH 043/200] tests: fix flaky SSL test by increasing connect timeout and retry budget The routes_visible() polling function in TestSslThroughNlb creates a new TestCluster with SSL on every retry attempt. Under resource pressure (--smp 2 --memory 2048M shared across 3 nodes), the SSL handshake plus CQL negotiation can exceed the default 5-second connect_timeout, causing intermittent OperationTimedOut failures. Fix by passing connect_timeout=30 to TestCluster (matching the generous timeout recommended for slow-starting clusters) and increasing the wait_until_not_raised parameters from (0.5, 10) to (1, 30), consistent with other wait_until_not_raised calls in this file (lines 773, 855). --- tests/integration/standard/test_client_routes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/standard/test_client_routes.py b/tests/integration/standard/test_client_routes.py index 9471c95867..5a20421276 100644 --- a/tests/integration/standard/test_client_routes.py +++ b/tests/integration/standard/test_client_routes.py @@ -1059,7 +1059,7 @@ def test_ssl_without_hostname_verification_through_nlb(self): def routes_visible(): with TestCluster( contact_points=["127.0.0.1"], - ssl_context=ssl_ctx, + ssl_context=ssl_ctx, connect_timeout=30, ) as c: session = c.connect() rs = session.execute( @@ -1071,7 +1071,7 @@ def routes_visible(): wait_until_not_raised( lambda: self.assertTrue(routes_visible()), - 0.5, 10, + 1, 30, ) with Cluster( From db317eb3645495232664f99c4a452da67b74903e Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Sun, 29 Mar 2026 16:32:08 +0300 Subject: [PATCH 044/200] tests: register custom 'last' pytest mark to suppress warning The test_tablets.py file uses @pytest.mark.last to ensure the decommission test runs last. Register this mark in pyproject.toml to eliminate the PytestUnknownMarkWarning. --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 7f60ed0b2a..1335027fcd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -121,6 +121,9 @@ log_level = "DEBUG" log_date_format = "%Y-%m-%d %H:%M:%S" xfail_strict = true addopts = "-rf" +markers = [ + "last: mark test to run last within its module group", +] [tool.setuptools_scm] version_file = "cassandra/_version.py" From 50941184b1c8e5a3aed2b23fc392a50bc540d63b Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Fri, 20 Mar 2026 21:03:47 +0200 Subject: [PATCH 045/200] perf: use stdlib bisect and attrgetter in tablets.py - Use bisect.bisect_left from stdlib unconditionally (C implementation); drop the bundled pure-Python fallback since we only support Python 3.10+ - Replace per-call lambda closures with module-level operator.attrgetter for first_token/last_token extraction - Add unit tests for get_tablet_for_key Benchmark results (get_tablet_for_key hit): 10 tablets: 517 ns -> 365 ns (1.42x) 100 tablets: 616 ns -> 351 ns (1.75x) 1000 tablets: 1008 ns -> 529 ns (1.91x) 10000 tablets: 1339 ns -> 610 ns (2.20x) --- cassandra/tablets.py | 48 +++++++------------------------------- tests/unit/test_tablets.py | 38 ++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 39 deletions(-) diff --git a/cassandra/tablets.py b/cassandra/tablets.py index dca26ab0df..96e61a50c2 100644 --- a/cassandra/tablets.py +++ b/cassandra/tablets.py @@ -1,7 +1,13 @@ +from bisect import bisect_left +from operator import attrgetter from threading import Lock from typing import Optional from uuid import UUID +# C-accelerated attrgetter avoids per-call lambda allocation overhead +_get_first_token = attrgetter("first_token") +_get_last_token = attrgetter("last_token") + class Tablet(object): """ @@ -57,7 +63,7 @@ def get_tablet_for_key(self, keyspace, table, t): if not tablet: return None - id = bisect_left(tablet, t.value, key=lambda tablet: tablet.last_token) + id = bisect_left(tablet, t.value, key=_get_last_token) if id < len(tablet) and t.value > tablet[id].first_token: return tablet[id] return None @@ -94,12 +100,12 @@ def add_tablet(self, keyspace, table, tablet): tablets_for_table = self._tablets.setdefault((keyspace, table), []) # find first overlapping range - start = bisect_left(tablets_for_table, tablet.first_token, key=lambda t: t.first_token) + start = bisect_left(tablets_for_table, tablet.first_token, key=_get_first_token) if start > 0 and tablets_for_table[start - 1].last_token > tablet.first_token: start = start - 1 # find last overlapping range - end = bisect_left(tablets_for_table, tablet.last_token, key=lambda t: t.last_token) + end = bisect_left(tablets_for_table, tablet.last_token, key=_get_last_token) if end < len(tablets_for_table) and tablets_for_table[end].first_token >= tablet.last_token: end = end - 1 @@ -108,39 +114,3 @@ def add_tablet(self, keyspace, table, tablet): tablets_for_table.insert(start, tablet) - -# bisect.bisect_left implementation from Python 3.11, needed untill support for -# Python < 3.10 is dropped, it is needed to use `key` to extract last_token from -# Tablet list - better solution performance-wise than materialize list of last_tokens -def bisect_left(a, x, lo=0, hi=None, *, key=None): - """Return the index where to insert item x in list a, assuming a is sorted. - - The return value i is such that all e in a[:i] have e < x, and all e in - a[i:] have e >= x. So if x already appears in the list, a.insert(i, x) will - insert just before the leftmost x already there. - - Optional args lo (default 0) and hi (default len(a)) bound the - slice of a to be searched. - """ - - if lo < 0: - raise ValueError('lo must be non-negative') - if hi is None: - hi = len(a) - # Note, the comparison uses "<" to match the - # __lt__() logic in list.sort() and in heapq. - if key is None: - while lo < hi: - mid = (lo + hi) // 2 - if a[mid] < x: - lo = mid + 1 - else: - hi = mid - return - while lo < hi: - mid = (lo + hi) // 2 - if key(a[mid]) < x: - lo = mid + 1 - else: - hi = mid - return lo diff --git a/tests/unit/test_tablets.py b/tests/unit/test_tablets.py index 5e640fa4c9..7a40e7de4d 100644 --- a/tests/unit/test_tablets.py +++ b/tests/unit/test_tablets.py @@ -86,3 +86,41 @@ def test_add_tablet_intersecting_with_last(self): self.compare_ranges(tablets_list, [(-8611686018427387905, -7917529027641081857), (-5011686018427387905, -2987529027641081857)]) + + +class GetTabletForKeyTest(unittest.TestCase): + """Tests for Tablets.get_tablet_for_key.""" + + def test_found(self): + t1 = Tablet(0, 100, [("host1", 0)]) + t2 = Tablet(100, 200, [("host2", 0)]) + t3 = Tablet(200, 300, [("host3", 0)]) + tablets = Tablets({("ks", "tb"): [t1, t2, t3]}) + + class Token: + def __init__(self, v): + self.value = v + + result = tablets.get_tablet_for_key("ks", "tb", Token(150)) + self.assertIs(result, t2) + + def test_not_found_empty(self): + tablets = Tablets({}) + + class Token: + def __init__(self, v): + self.value = v + + self.assertIsNone(tablets.get_tablet_for_key("ks", "tb", Token(50))) + + def test_not_found_outside_range(self): + t1 = Tablet(100, 200, [("host1", 0)]) + tablets = Tablets({("ks", "tb"): [t1]}) + + class Token: + def __init__(self, v): + self.value = v + + # Token value 50 is not > first_token (100) of the tablet whose + # last_token (200) is >= 50, so no match. + self.assertIsNone(tablets.get_tablet_for_key("ks", "tb", Token(50))) From cc78c22b173c08c4ba7843306a7a77ac934f18fd Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Sun, 12 Apr 2026 22:39:13 -0400 Subject: [PATCH 046/200] Add Jira PR sync workflow --- .github/workflows/call_jira_sync.yml | 41 ++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/call_jira_sync.yml diff --git a/.github/workflows/call_jira_sync.yml b/.github/workflows/call_jira_sync.yml new file mode 100644 index 0000000000..385737847b --- /dev/null +++ b/.github/workflows/call_jira_sync.yml @@ -0,0 +1,41 @@ +name: Sync Jira Based on PR Events + +on: + pull_request_target: + types: [opened, edited, ready_for_review, review_requested, labeled, unlabeled, closed] + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + jira-sync-pr-opened: + if: github.event.action == 'opened' || github.event.action == 'edited' + uses: scylladb/github-automation/.github/workflows/main_jira_sync_pr_opened.yml@main + secrets: + caller_jira_auth: ${{ secrets.USER_AND_KEY_FOR_JIRA_AUTOMATION }} + + jira-sync-in-review: + if: github.event.action == 'ready_for_review' || github.event.action == 'review_requested' + uses: scylladb/github-automation/.github/workflows/main_jira_sync_in_review.yml@main + secrets: + caller_jira_auth: ${{ secrets.USER_AND_KEY_FOR_JIRA_AUTOMATION }} + + jira-sync-add-label: + if: github.event.action == 'labeled' + uses: scylladb/github-automation/.github/workflows/main_jira_sync_add_label.yml@main + secrets: + caller_jira_auth: ${{ secrets.USER_AND_KEY_FOR_JIRA_AUTOMATION }} + + jira-sync-remove-label: + if: github.event.action == 'unlabeled' + uses: scylladb/github-automation/.github/workflows/main_jira_sync_remove_label.yml@main + secrets: + caller_jira_auth: ${{ secrets.USER_AND_KEY_FOR_JIRA_AUTOMATION }} + + jira-sync-pr-closed: + if: github.event.action == 'closed' + uses: scylladb/github-automation/.github/workflows/main_jira_sync_pr_closed.yml@main + secrets: + caller_jira_auth: ${{ secrets.USER_AND_KEY_FOR_JIRA_AUTOMATION }} From d2e3fef87c3aa58a3e68da0b23e034b264044d64 Mon Sep 17 00:00:00 2001 From: Dani Tweig Date: Tue, 14 Apr 2026 16:49:52 +0300 Subject: [PATCH 047/200] PM-285: Consolidate Jira sync workflow to single job calling main_pr_events_jira_sync --- .github/workflows/call_jira_sync.yml | 31 ++++------------------------ 1 file changed, 4 insertions(+), 27 deletions(-) diff --git a/.github/workflows/call_jira_sync.yml b/.github/workflows/call_jira_sync.yml index 385737847b..14f517df40 100644 --- a/.github/workflows/call_jira_sync.yml +++ b/.github/workflows/call_jira_sync.yml @@ -10,32 +10,9 @@ permissions: issues: write jobs: - jira-sync-pr-opened: - if: github.event.action == 'opened' || github.event.action == 'edited' - uses: scylladb/github-automation/.github/workflows/main_jira_sync_pr_opened.yml@main - secrets: - caller_jira_auth: ${{ secrets.USER_AND_KEY_FOR_JIRA_AUTOMATION }} - - jira-sync-in-review: - if: github.event.action == 'ready_for_review' || github.event.action == 'review_requested' - uses: scylladb/github-automation/.github/workflows/main_jira_sync_in_review.yml@main - secrets: - caller_jira_auth: ${{ secrets.USER_AND_KEY_FOR_JIRA_AUTOMATION }} - - jira-sync-add-label: - if: github.event.action == 'labeled' - uses: scylladb/github-automation/.github/workflows/main_jira_sync_add_label.yml@main - secrets: - caller_jira_auth: ${{ secrets.USER_AND_KEY_FOR_JIRA_AUTOMATION }} - - jira-sync-remove-label: - if: github.event.action == 'unlabeled' - uses: scylladb/github-automation/.github/workflows/main_jira_sync_remove_label.yml@main - secrets: - caller_jira_auth: ${{ secrets.USER_AND_KEY_FOR_JIRA_AUTOMATION }} - - jira-sync-pr-closed: - if: github.event.action == 'closed' - uses: scylladb/github-automation/.github/workflows/main_jira_sync_pr_closed.yml@main + jira-sync: + uses: scylladb/github-automation/.github/workflows/main_pr_events_jira_sync.yml@main + with: + caller_action: ${{ github.event.action }} secrets: caller_jira_auth: ${{ secrets.USER_AND_KEY_FOR_JIRA_AUTOMATION }} From 006babf87f550afdc5f3e03f4080783d2ed48683 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 08:40:31 +0000 Subject: [PATCH 048/200] chore(deps): update github artifact actions --- .github/workflows/build-push.yml | 2 +- .github/workflows/lib-build-and-push.yml | 6 +++--- .github/workflows/publish-manually.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build-push.yml b/.github/workflows/build-push.yml index 15c77f3861..7414daec3a 100644 --- a/.github/workflows/build-push.yml +++ b/.github/workflows/build-push.yml @@ -23,7 +23,7 @@ jobs: permissions: id-token: write steps: - - uses: actions/download-artifact@v7 + - uses: actions/download-artifact@v8 with: path: dist merge-multiple: true diff --git a/.github/workflows/lib-build-and-push.yml b/.github/workflows/lib-build-and-push.yml index 735a4638f4..0b1ce47647 100644 --- a/.github/workflows/lib-build-and-push.yml +++ b/.github/workflows/lib-build-and-push.yml @@ -153,7 +153,7 @@ jobs: run: | GITHUB_WORKFLOW_REF="scylladb/python-driver/.github/workflows/lib-build-and-push.yml@refs/heads/master" CIBW_BUILD="cp3*" cibuildwheel --archs aarch64 --output-dir wheelhouse - - uses: actions/upload-artifact@v6 + - uses: actions/upload-artifact@v7 with: name: wheels-${{ matrix.target }}-${{ matrix.os }} path: ./wheelhouse/*.whl @@ -172,7 +172,7 @@ jobs: - name: Build sdist run: uv build --sdist - - uses: actions/upload-artifact@v6 + - uses: actions/upload-artifact@v7 with: name: source-dist path: dist/*.tar.gz @@ -185,7 +185,7 @@ jobs: id-token: write steps: - - uses: actions/download-artifact@v7 + - uses: actions/download-artifact@v8 with: path: dist merge-multiple: true diff --git a/.github/workflows/publish-manually.yml b/.github/workflows/publish-manually.yml index 09b9779117..83ed290a2b 100644 --- a/.github/workflows/publish-manually.yml +++ b/.github/workflows/publish-manually.yml @@ -56,7 +56,7 @@ jobs: permissions: id-token: write steps: - - uses: actions/download-artifact@v7 + - uses: actions/download-artifact@v8 with: path: dist merge-multiple: true From 293e4a15ed190bcb07e43dfba606e8f1fb1a8936 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 08:40:27 +0000 Subject: [PATCH 049/200] chore(deps): update actions/cache action to v5 --- .github/workflows/integration-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 3c75a33603..89f62963b0 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -78,7 +78,7 @@ jobs: run: uv sync - name: Cache Scylla download - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.ccm/repository key: scylla-${{ env.SCYLLA_VERSION }}-${{ runner.os }} From ee0bc66078322bd5d4e856a8868ba208cef6b752 Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Wed, 15 Apr 2026 13:12:46 +0200 Subject: [PATCH 050/200] CI: fix id-token permission for Test wheels building MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build-test.yml triggers on pull_request, which gives it id-token:none by default. lib-build-and-push.yml's upload_pypi job declares id-token:write, which exceeds the caller's cap and causes GitHub to reject the workflow at parse time — even though upload:false prevents upload_pypi from ever running. Fix: explicitly grant id-token:write to the test-wheels-build job so the permission cap satisfies the reusable workflow's requirement. Fixes #819 --- .github/workflows/build-test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 3e1f1067d7..b0d261d9d6 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -19,5 +19,7 @@ jobs: name: "Test wheels building" if: "!contains(github.event.pull_request.labels.*.name, 'disable-test-build')" uses: ./.github/workflows/lib-build-and-push.yml + permissions: + id-token: write with: upload: false \ No newline at end of file From 284bd90f5db6844768fa88bcba07896b20fa96dc Mon Sep 17 00:00:00 2001 From: David Garcia Date: Thu, 12 Mar 2026 12:34:52 +0000 Subject: [PATCH 051/200] docs: update theme 1.9 --- .github/dependabot.yml | 2 +- .github/workflows/docs-pr.yml | 3 ++ docs/.gitignore | 2 + docs/conf.py | 2 +- docs/pyproject.toml | 8 ++-- docs/uv.lock | 89 +++++++++++++---------------------- 6 files changed, 44 insertions(+), 62 deletions(-) create mode 100644 docs/.gitignore diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 28784749c4..ac3943ef57 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,6 +1,6 @@ version: 2 updates: - - package-ecosystem: "pip" + - package-ecosystem: "uv" directory: "/docs" schedule: interval: "daily" diff --git a/.github/workflows/docs-pr.yml b/.github/workflows/docs-pr.yml index b5651c8159..4158c2912e 100644 --- a/.github/workflows/docs-pr.yml +++ b/.github/workflows/docs-pr.yml @@ -2,6 +2,9 @@ name: "Docs / Build PR" # For more information, # see https://sphinx-theme.scylladb.com/stable/deployment/production.html#available-workflows +permissions: + contents: read + on: push: branches: diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000000..733bc65597 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,2 @@ +# Track uv.lock for reproducible docs builds +!uv.lock diff --git a/docs/conf.py b/docs/conf.py index 4b6b329525..87a38c6add 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -52,7 +52,7 @@ 'sphinx_sitemap', 'sphinx_scylladb_theme', 'sphinx_multiversion', # optional - 'recommonmark', # optional + 'myst_parser', # optional ] # Add any paths that contain templates here, relative to this directory. diff --git a/docs/pyproject.toml b/docs/pyproject.toml index 59c425229a..762a4f2e49 100644 --- a/docs/pyproject.toml +++ b/docs/pyproject.toml @@ -11,13 +11,13 @@ dependencies = [ "gevent>=25.9.1,<26.0.0", "gremlinpython==3.7.4", "pygments>=2.19.2,<3.0.0", - "recommonmark==0.7.1", + "myst-parser>=5.0.0", "redirects_cli~=0.1.3", "sphinx-autobuild>=2025.0.0,<2026.0.0", "sphinx-sitemap>=2.8.0,<3.0.0", - "sphinx-scylladb-theme>=1.8.2,<2.0.0", + "sphinx-scylladb-theme>=1.9.1", "sphinx-multiversion-scylla>=0.3.2,<1.0.0", - "sphinx>=8.2.3,<9.0.0", + "sphinx>=9.0", "six>=1.9", "tornado>=6.5,<7.0", ] @@ -57,4 +57,4 @@ exclude = [ "**/__pycache__/**", "**/*.pyc", ".venv/**", -] \ No newline at end of file +] diff --git a/docs/uv.lock b/docs/uv.lock index 720a2080e7..56b0841403 100644 --- a/docs/uv.lock +++ b/docs/uv.lock @@ -205,15 +205,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] -[[package]] -name = "commonmark" -version = "0.9.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/60/48/a60f593447e8f0894ebb7f6e6c1f25dafc5e89c5879fdc9360ae93ff83f0/commonmark-0.9.1.tar.gz", hash = "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60", size = 95764, upload-time = "2019-10-04T15:37:39.817Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/92/dfd892312d822f36c55366118b95d914e5f16de11044a27cf10a7d71bbbf/commonmark-0.9.1-py2.py3-none-any.whl", hash = "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9", size = 51068, upload-time = "2019-10-04T15:37:37.674Z" }, -] - [[package]] name = "dnspython" version = "2.8.0" @@ -405,14 +396,14 @@ wheels = [ [[package]] name = "markdown-it-py" -version = "3.0.0" +version = "4.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] [[package]] @@ -513,7 +504,7 @@ wheels = [ [[package]] name = "myst-parser" -version = "4.0.1" +version = "5.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docutils" }, @@ -523,9 +514,9 @@ dependencies = [ { name = "pyyaml" }, { name = "sphinx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/a5/9626ba4f73555b3735ad86247a8077d4603aa8628537687c839ab08bfe44/myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4", size = 93985, upload-time = "2025-02-12T10:53:03.833Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/fa/7b45eef11b7971f0beb29d27b7bfe0d747d063aa29e170d9edd004733c8a/myst_parser-5.0.0.tar.gz", hash = "sha256:f6f231452c56e8baa662cc352c548158f6a16fcbd6e3800fc594978002b94f3a", size = 98535, upload-time = "2026-01-15T09:08:18.036Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/df/76d0321c3797b54b60fef9ec3bd6f4cfd124b9e422182156a1dd418722cf/myst_parser-4.0.1-py3-none-any.whl", hash = "sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d", size = 84579, upload-time = "2025-02-12T10:53:02.078Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/686789b9145413f1a61878c407210e41bfdb097976864e0913078b24098c/myst_parser-5.0.0-py3-none-any.whl", hash = "sha256:ab31e516024918296e169139072b81592336f2fef55b8986aa31c9f04b5f7211", size = 84533, upload-time = "2026-01-15T09:08:16.788Z" }, ] [[package]] @@ -548,11 +539,11 @@ wheels = [ [[package]] name = "pathspec" -version = "0.12.1" +version = "1.0.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, ] [[package]] @@ -629,8 +620,8 @@ dependencies = [ { name = "eventlet" }, { name = "gevent" }, { name = "gremlinpython" }, + { name = "myst-parser" }, { name = "pygments" }, - { name = "recommonmark" }, { name = "redirects-cli" }, { name = "six" }, { name = "sphinx" }, @@ -651,14 +642,14 @@ requires-dist = [ { name = "eventlet", specifier = ">=0.40.3,<1.0.0" }, { name = "gevent", specifier = ">=25.9.1,<26.0.0" }, { name = "gremlinpython", specifier = "==3.7.4" }, + { name = "myst-parser", specifier = ">=5.0.0" }, { name = "pygments", specifier = ">=2.19.2,<3.0.0" }, - { name = "recommonmark", specifier = "==0.7.1" }, { name = "redirects-cli", specifier = "~=0.1.3" }, { name = "six", specifier = ">=1.9" }, - { name = "sphinx", specifier = ">=8.2.3,<9.0.0" }, + { name = "sphinx", specifier = ">=9.0" }, { name = "sphinx-autobuild", specifier = ">=2025.0.0,<2026.0.0" }, { name = "sphinx-multiversion-scylla", specifier = ">=0.3.2,<1.0.0" }, - { name = "sphinx-scylladb-theme", specifier = ">=1.8.2,<2.0.0" }, + { name = "sphinx-scylladb-theme", specifier = ">=1.9.1" }, { name = "sphinx-sitemap", specifier = ">=2.8.0,<3.0.0" }, { name = "tornado", specifier = ">=6.5,<7.0" }, ] @@ -684,20 +675,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, ] -[[package]] -name = "recommonmark" -version = "0.7.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "commonmark" }, - { name = "docutils" }, - { name = "sphinx" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1c/00/3dd2bdc4184b0ce754b5b446325abf45c2e0a347e022292ddc44670f628c/recommonmark-0.7.1.tar.gz", hash = "sha256:bdb4db649f2222dcd8d2d844f0006b958d627f732415d399791ee436a3686d67", size = 34444, upload-time = "2020-12-17T19:24:56.523Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/77/ed589c75db5d02a77a1d5d2d9abc63f29676467d396c64277f98b50b79c2/recommonmark-0.7.1-py2.py3-none-any.whl", hash = "sha256:1b1db69af0231efce3fa21b94ff627ea33dee7079a01dd0a7f8482c3da148b3f", size = 10214, upload-time = "2020-12-17T19:24:55.137Z" }, -] - [[package]] name = "redirects-cli" version = "0.1.3" @@ -740,12 +717,12 @@ wheels = [ ] [[package]] -name = "roman-numerals-py" -version = "3.1.0" +name = "roman-numerals" +version = "4.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/76/48fd56d17c5bdbdf65609abbc67288728a98ed4c02919428d4f52d23b24b/roman_numerals_py-3.1.0.tar.gz", hash = "sha256:be4bf804f083a4ce001b5eb7e3c0862479d10f94c936f6c4e5f250aa5ff5bd2d", size = 9017, upload-time = "2025-02-22T07:34:54.333Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/53/97/d2cbbaa10c9b826af0e10fdf836e1bf344d9f0abb873ebc34d1f49642d3f/roman_numerals_py-3.1.0-py3-none-any.whl", hash = "sha256:9da2ad2fb670bcf24e81070ceb3be72f6c11c440d73bd579fbeca1e9f330954c", size = 7742, upload-time = "2025-02-22T07:34:52.422Z" }, + { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, ] [[package]] @@ -795,7 +772,7 @@ wheels = [ [[package]] name = "sphinx" -version = "8.2.3" +version = "9.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "alabaster" }, @@ -807,7 +784,7 @@ dependencies = [ { name = "packaging" }, { name = "pygments" }, { name = "requests" }, - { name = "roman-numerals-py" }, + { name = "roman-numerals" }, { name = "snowballstemmer" }, { name = "sphinxcontrib-applehelp" }, { name = "sphinxcontrib-devhelp" }, @@ -816,9 +793,9 @@ dependencies = [ { name = "sphinxcontrib-qthelp" }, { name = "sphinxcontrib-serializinghtml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/ad/4360e50ed56cb483667b8e6dadf2d3fda62359593faabbe749a27c4eaca6/sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348", size = 8321876, upload-time = "2025-03-02T22:31:59.658Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/53/136e9eca6e0b9dc0e1962e2c908fbea2e5ac000c2a2fbd9a35797958c48b/sphinx-8.2.3-py3-none-any.whl", hash = "sha256:4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3", size = 3589741, upload-time = "2025-03-02T22:31:56.836Z" }, + { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, ] [[package]] @@ -840,14 +817,14 @@ wheels = [ [[package]] name = "sphinx-collapse" -version = "0.1.3" +version = "0.1.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sphinx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e7/02/183559e508906f7282d4dd6ccbf443efddaa3114b7f6fab425949b37a003/sphinx_collapse-0.1.3.tar.gz", hash = "sha256:cae141e6f03ecd52ed246a305a69e1b0d5d05e6cdf3fe803d40d583ad6ad895a", size = 18540, upload-time = "2024-02-22T15:24:38.735Z" } +sdist = { url = "https://files.pythonhosted.org/packages/14/a1/cb5bb03a5081bd1229b3296c2af347b4147017fdb62777d2aad855cd349f/sphinx_collapse-0.1.4.tar.gz", hash = "sha256:ba860e50839c026cd1abcc164e1e7cb18bcc11c8214150e34a6550461be3229f", size = 19412, upload-time = "2026-02-27T17:47:24.191Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/2f/5889082a6a535aa8613a327308582914517082967583ad45586b7d61c145/sphinx_collapse-0.1.3-py3-none-any.whl", hash = "sha256:85fadb2ec8769b93fd04276538668fa96239ef60c20c4a9eaa3e480387a6e65b", size = 4688, upload-time = "2024-02-22T15:24:29.365Z" }, + { url = "https://files.pythonhosted.org/packages/9a/18/277f4663c97073606917becab629938237f1e03952f4e339f8b7d1f3096b/sphinx_collapse-0.1.4-py3-none-any.whl", hash = "sha256:76e9fa531bafb4984d6ef5f3dbe311982837f5965b7a35eda013bbd9dd41445e", size = 4811, upload-time = "2026-02-27T17:47:22.622Z" }, ] [[package]] @@ -876,14 +853,14 @@ wheels = [ [[package]] name = "sphinx-multiversion-scylla" -version = "0.3.4" +version = "0.3.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sphinx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/48/1d/e2b1a214b20d33cc631422e483ed1c8cf6883870940b58cc46341b65e2d7/sphinx_multiversion_scylla-0.3.4.tar.gz", hash = "sha256:8f7c94a89c794334d78ef21761a8bf455aaa7361e71037cf2ac2ca51cb47a0ba", size = 12427, upload-time = "2025-11-24T07:42:01.506Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/b1/83fb37f6c9038469b3bd01453875bb2127b3c03f9f41247394ad2063645c/sphinx_multiversion_scylla-0.3.7.tar.gz", hash = "sha256:fc1ddd58e82cfd8810c1be6db8717a244043c04c1c632e9bd1436415d1db0d3b", size = 12665, upload-time = "2026-02-27T18:43:17.849Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/aa/82c27991640fe47921f74894a192d374dc1eb609d2276de4abeefe85f4aa/sphinx_multiversion_scylla-0.3.4-py3-none-any.whl", hash = "sha256:e64d49d39a8eccf06a9cb8bbe88eecb3eb2082e6b91a478b55dc7d0268d8e0b6", size = 12302, upload-time = "2025-11-24T07:42:00.403Z" }, + { url = "https://files.pythonhosted.org/packages/a1/94/f5b6219ca1136dc0305aaf3fb6c96aa2dfe65224d6dc147e00a6485a1a22/sphinx_multiversion_scylla-0.3.7-py3-none-any.whl", hash = "sha256:6205d261a77c90b7ea3105311d1d56014736a5148966133c34344512bb8c4e4f", size = 12558, upload-time = "2026-02-27T18:43:16.988Z" }, ] [[package]] @@ -900,7 +877,7 @@ wheels = [ [[package]] name = "sphinx-scylladb-theme" -version = "1.8.10" +version = "1.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "beautifulsoup4" }, @@ -913,9 +890,9 @@ dependencies = [ { name = "sphinx-tabs" }, { name = "sphinxcontrib-mermaid" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/18/cd/bbd41f0d058f0ef4997cb044326f15dd28a1a17a4336e9b52cb67b8dd242/sphinx_scylladb_theme-1.8.10.tar.gz", hash = "sha256:8a78a9b692d9a946be2c4a64aa472fd82204cc8ea0b1ee7f60de6db35b356326", size = 1620675, upload-time = "2025-12-05T16:49:38.942Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/4e/e49e351d4c429b8fe3090657d39e956d53dff61187d783caac1cba81bd72/sphinx_scylladb_theme-1.9.1.tar.gz", hash = "sha256:2ba6367f005d2c68eee1916cc16385989b8e53bbddcc81193003bdeb3bd3415e", size = 1676201, upload-time = "2026-03-09T18:10:43.841Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4c/0e/7577d9bb6e2e7378e6c9f49263c59061a2ae9e370b806d8d1fd8c3be2a23/sphinx_scylladb_theme-1.8.10-py3-none-any.whl", hash = "sha256:8b930f33bec7308ccaa92698ebb5ad85059bcbf93a463f92917aeaf473fce632", size = 1662434, upload-time = "2025-12-05T16:49:36.265Z" }, + { url = "https://files.pythonhosted.org/packages/4f/30/2b2bae1b022d1fabef405a4857f160464548e08d924f24d0b26d0ca6a848/sphinx_scylladb_theme-1.9.1-py3-none-any.whl", hash = "sha256:6156d60befc3da03bd11991fec9bc590e27ce7cc4ab05aa334edd5611424b106", size = 1662204, upload-time = "2026-03-09T18:10:45.638Z" }, ] [[package]] @@ -1057,11 +1034,11 @@ wheels = [ [[package]] name = "trove-classifiers" -version = "2025.12.1.14" +version = "2026.1.14.14" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/80/e1/000add3b3e0725ce7ee0ea6ea4543f1e1d9519742f3b2320de41eeefa7c7/trove_classifiers-2025.12.1.14.tar.gz", hash = "sha256:a74f0400524fc83620a9be74a07074b5cbe7594fd4d97fd4c2bfde625fdc1633", size = 16985, upload-time = "2025-12-01T14:47:11.456Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/43/7935f8ea93fcb6680bc10a6fdbf534075c198eeead59150dd5ed68449642/trove_classifiers-2026.1.14.14.tar.gz", hash = "sha256:00492545a1402b09d4858605ba190ea33243d361e2b01c9c296ce06b5c3325f3", size = 16997, upload-time = "2026-01-14T14:54:50.526Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/7e/bc19996fa86cad8801e8ffe6f1bba5836ca0160df76d0410d27432193712/trove_classifiers-2025.12.1.14-py3-none-any.whl", hash = "sha256:a8206978ede95937b9959c3aff3eb258bbf7b07dff391ddd4ea7e61f316635ab", size = 14184, upload-time = "2025-12-01T14:47:10.113Z" }, + { url = "https://files.pythonhosted.org/packages/bb/4a/2e5583e544bc437d5e8e54b47db87430df9031b29b48d17f26d129fa60c0/trove_classifiers-2026.1.14.14-py3-none-any.whl", hash = "sha256:1f9553927f18d0513d8e5ff80ab8980b8202ce37ecae0e3274ed2ef11880e74d", size = 14197, upload-time = "2026-01-14T14:54:49.067Z" }, ] [[package]] From ca5b8c244de0c162dcb002728c53ac10fe4537a7 Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Thu, 16 Apr 2026 21:25:48 +0200 Subject: [PATCH 052/200] pool: fix inverted cooldown check in _get_shard_aware_endpoint The `block_until < time.time()` condition was true only *after* the NAT-detection cooldown had already expired, so the shard-aware port was never suppressed during the 10-minute window and was permanently disabled once that window closed. Fix: flip to `>` so the guard fires while the deadline is in the future. Add unit test covering the active-block, expired-block, and hard-disable paths to prevent regression. --- cassandra/pool.py | 2 +- tests/unit/test_shard_aware.py | 138 +++++++++++++++++++++------------ 2 files changed, 90 insertions(+), 50 deletions(-) diff --git a/cassandra/pool.py b/cassandra/pool.py index 227e1b5315..9e949c342c 100644 --- a/cassandra/pool.py +++ b/cassandra/pool.py @@ -677,7 +677,7 @@ def disable_advanced_shard_aware(self, secs): self.advanced_shardaware_block_until = max(time.time() + secs, self.advanced_shardaware_block_until) def _get_shard_aware_endpoint(self): - if (self.advanced_shardaware_block_until and self.advanced_shardaware_block_until < time.time()) or \ + if (self.advanced_shardaware_block_until and self.advanced_shardaware_block_until > time.time()) or \ self._session.cluster.shard_aware_options.disable_shardaware_port: return None diff --git a/tests/unit/test_shard_aware.py b/tests/unit/test_shard_aware.py index e7d26ae207..4b4c2c138d 100644 --- a/tests/unit/test_shard_aware.py +++ b/tests/unit/test_shard_aware.py @@ -15,6 +15,7 @@ import unittest import logging +import time from unittest.mock import MagicMock from concurrent.futures import ThreadPoolExecutor @@ -27,6 +28,45 @@ LOGGER = logging.getLogger(__name__) +class MockSession(MagicMock): + is_shutdown = False + keyspace = "ks1" + + def __init__(self, is_ssl=False, *args, **kwargs): + super(MockSession, self).__init__(*args, **kwargs) + self.cluster = MagicMock() + if is_ssl: + self.cluster.ssl_options = {'some_ssl_options': True} + else: + self.cluster.ssl_options = None + self.cluster.shard_aware_options = ShardAwareOptions() + self.cluster.executor = ThreadPoolExecutor(max_workers=2) + self.cluster.signal_connection_failure = lambda *args, **kwargs: False + self.cluster.connection_factory = self.mock_connection_factory + self.connection_counter = 0 + self.futures = [] + + def submit(self, fn, *args, **kwargs): + logging.info("Scheduling %s with args: %s, kwargs: %s", fn, args, kwargs) + if not self.is_shutdown: + f = self.cluster.executor.submit(fn, *args, **kwargs) + self.futures += [f] + return f + + def mock_connection_factory(self, *args, **kwargs): + connection = MagicMock() + connection.is_shutdown = False + connection.is_defunct = False + connection.is_closed = False + connection.orphaned_threshold_reached = False + connection.endpoint = args[0] + sharding_info = ShardingInfo(shard_id=1, shards_count=4, partitioner="", sharding_algorithm="", sharding_ignore_msb=0, shard_aware_port=19042, shard_aware_port_ssl=19045) + connection.features = ProtocolFeatures(shard_id=kwargs.get('shard_id', self.connection_counter), sharding_info=sharding_info) + self.connection_counter += 1 + + return connection + + class TestShardAware(unittest.TestCase): def test_parsing_and_calculating_shard_id(self): """ @@ -55,58 +95,58 @@ def test_advanced_shard_aware_port(self): Test that on given a `shard_aware_port` on the OPTIONS message (ShardInfo class) the next connections would be open using this port """ - class MockSession(MagicMock): - is_shutdown = False - keyspace = "ks1" - - def __init__(self, is_ssl=False, *args, **kwargs): - super(MockSession, self).__init__(*args, **kwargs) - self.cluster = MagicMock() - if is_ssl: - self.cluster.ssl_options = {'some_ssl_options': True} - else: - self.cluster.ssl_options = None - self.cluster.shard_aware_options = ShardAwareOptions() - self.cluster.executor = ThreadPoolExecutor(max_workers=2) - self.cluster.signal_connection_failure = lambda *args, **kwargs: False - self.cluster.connection_factory = self.mock_connection_factory - self.connection_counter = 0 - self.futures = [] - - def submit(self, fn, *args, **kwargs): - logging.info("Scheduling %s with args: %s, kwargs: %s", fn, args, kwargs) - if not self.is_shutdown: - f = self.cluster.executor.submit(fn, *args, **kwargs) - self.futures += [f] - return f - - def mock_connection_factory(self, *args, **kwargs): - connection = MagicMock() - connection.is_shutdown = False - connection.is_defunct = False - connection.is_closed = False - connection.orphaned_threshold_reached = False - connection.endpoint = args[0] - sharding_info = ShardingInfo(shard_id=1, shards_count=4, partitioner="", sharding_algorithm="", sharding_ignore_msb=0, shard_aware_port=19042, shard_aware_port_ssl=19045) - connection.features = ProtocolFeatures(shard_id=kwargs.get('shard_id', self.connection_counter), sharding_info=sharding_info) - self.connection_counter += 1 - - return connection - host = MagicMock() host.endpoint = DefaultEndPoint("1.2.3.4") for port, is_ssl in [(19042, False), (19045, True)]: session = MockSession(is_ssl=is_ssl) pool = HostConnection(host=host, host_distance=HostDistance.REMOTE, session=session) - for f in session.futures: - f.result() - assert len(pool._connections) == 4 - for shard_id, connection in pool._connections.items(): - assert connection.features.shard_id == shard_id - if shard_id == 0: - assert connection.endpoint == DefaultEndPoint("1.2.3.4") - else: - assert connection.endpoint == DefaultEndPoint("1.2.3.4", port=port) - - session.cluster.executor.shutdown(wait=True) + try: + for f in session.futures: + f.result() + assert len(pool._connections) == 4 + for shard_id, connection in pool._connections.items(): + assert connection.features.shard_id == shard_id + if shard_id == 0: + assert connection.endpoint == DefaultEndPoint("1.2.3.4") + else: + assert connection.endpoint == DefaultEndPoint("1.2.3.4", port=port) + finally: + session.cluster.executor.shutdown(wait=True) + + def test_advanced_shard_aware_cooldown(self): + """ + `disable_advanced_shard_aware` must suppress the shard-aware endpoint for + the duration of the cool-down window, then automatically restore it once + the deadline has passed. The hard-disable flag must suppress the endpoint + unconditionally. + """ + host = MagicMock() + host.endpoint = DefaultEndPoint("1.2.3.4") + session = MockSession(is_ssl=False) + + pool = HostConnection(host=host, host_distance=HostDistance.REMOTE, session=session) + for f in session.futures: + f.result() + + try: + # Baseline: shard-aware port is returned. + endpoint = pool._get_shard_aware_endpoint() + assert endpoint is not None + assert endpoint.port == 19042 + + # During the cool-down window `_get_shard_aware_endpoint` must return None. + pool.disable_advanced_shard_aware(600) + assert pool._get_shard_aware_endpoint() is None + + # Once the deadline has passed, the shard-aware port must be used again. + pool.advanced_shardaware_block_until = time.time() - 1 + endpoint = pool._get_shard_aware_endpoint() + assert endpoint is not None + assert endpoint.port == 19042 + + # The hard-disable flag must suppress the endpoint regardless of the timer. + session.cluster.shard_aware_options.disable_shardaware_port = True + assert pool._get_shard_aware_endpoint() is None + finally: + session.cluster.executor.shutdown(wait=True) From 11b427544fc26ba54b03dbd83291abb95235066a Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Fri, 17 Apr 2026 11:03:16 +0200 Subject: [PATCH 053/200] CI: remove dead upload_pypi job from reusable workflow, rename to lib-build.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #824. Follow-up to #820. The upload_pypi job in lib-build-and-push.yml was never reachable: none of the four caller workflows pass upload: true. build-push.yml and publish-manually.yml already publish from their own separate jobs (necessary due to how PyPI Trusted Publishing embeds the caller workflow path in the OIDC token). Because the reusable workflow declared 'permissions: id-token: write' for upload_pypi, GitHub's static permission validation forced build-test.yml (a pull_request workflow, which defaults to id-token: none) to also declare id-token: write — granting unnecessary privileges to a job that only builds wheels. Changes: - Rename lib-build-and-push.yml -> lib-build.yml (it only builds now) - Remove upload input and upload_pypi job from the reusable workflow - Remove 'permissions: id-token: write' and 'with: upload: false' from build-test.yml (no longer needed) - Update all callers (build-push.yml, publish-manually.yml, build-pre-release.yml) to reference the new workflow path and drop upload: false from with: blocks - Replace TODO comments in build-push.yml and publish-manually.yml with an explanatory comment: the separate publish job is now intentional design, not a temporary workaround --- .github/workflows/build-pre-release.yml | 2 +- .github/workflows/build-push.yml | 9 +++--- .github/workflows/build-test.yml | 6 +--- .../{lib-build-and-push.yml => lib-build.yml} | 29 ++----------------- .github/workflows/publish-manually.yml | 8 +++-- 5 files changed, 15 insertions(+), 39 deletions(-) rename .github/workflows/{lib-build-and-push.yml => lib-build.yml} (88%) diff --git a/.github/workflows/build-pre-release.yml b/.github/workflows/build-pre-release.yml index e1326b6aa5..f6473c1cc3 100644 --- a/.github/workflows/build-pre-release.yml +++ b/.github/workflows/build-pre-release.yml @@ -15,7 +15,7 @@ on: jobs: build-and-publish: - uses: ./.github/workflows/lib-build-and-push.yml + uses: ./.github/workflows/lib-build.yml with: python-version: ${{ inputs.python-version }} target: ${{ inputs.target }} diff --git a/.github/workflows/build-push.yml b/.github/workflows/build-push.yml index 7414daec3a..3a3d93171a 100644 --- a/.github/workflows/build-push.yml +++ b/.github/workflows/build-push.yml @@ -10,11 +10,12 @@ on: jobs: build-and-publish: name: "Build wheels" - uses: ./.github/workflows/lib-build-and-push.yml - with: - upload: false + uses: ./.github/workflows/lib-build.yml - # TODO: Remove when https://github.com/pypa/gh-action-pypi-publish/issues/166 is fixed and update build-and-publish.with.upload to ${{ endsWith(github.event.ref, 'scylla') }} + # Publishing is a separate job (not inside the reusable workflow) because PyPI Trusted Publishing + # requires the *caller* workflow path in the OIDC token. A reusable workflow would embed its own + # path instead, causing an `invalid-publisher` error on the PyPI side. + # See: https://github.com/pypa/gh-action-pypi-publish/issues/166 publish: name: "Publish wheels to PyPi" if: ${{ endsWith(github.event.ref, 'scylla') }} diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index b0d261d9d6..ebfe383047 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -18,8 +18,4 @@ jobs: test-wheels-build: name: "Test wheels building" if: "!contains(github.event.pull_request.labels.*.name, 'disable-test-build')" - uses: ./.github/workflows/lib-build-and-push.yml - permissions: - id-token: write - with: - upload: false \ No newline at end of file + uses: ./.github/workflows/lib-build.yml \ No newline at end of file diff --git a/.github/workflows/lib-build-and-push.yml b/.github/workflows/lib-build.yml similarity index 88% rename from .github/workflows/lib-build-and-push.yml rename to .github/workflows/lib-build.yml index 0b1ce47647..f8d0d7a4cc 100644 --- a/.github/workflows/lib-build-and-push.yml +++ b/.github/workflows/lib-build.yml @@ -1,14 +1,8 @@ -name: Build and upload to PyPi +name: Build wheels on: workflow_call: inputs: - upload: - description: 'Upload to PyPI' - type: boolean - required: false - default: false - python-version: description: 'Python version to run on' type: string @@ -146,12 +140,12 @@ jobs: if: matrix.target != 'linux-aarch64' shell: bash run: | - GITHUB_WORKFLOW_REF="scylladb/python-driver/.github/workflows/lib-build-and-push.yml@refs/heads/master" cibuildwheel --output-dir wheelhouse + GITHUB_WORKFLOW_REF="scylladb/python-driver/.github/workflows/lib-build.yml@refs/heads/master" cibuildwheel --output-dir wheelhouse - name: Build wheels for linux aarch64 if: matrix.target == 'linux-aarch64' run: | - GITHUB_WORKFLOW_REF="scylladb/python-driver/.github/workflows/lib-build-and-push.yml@refs/heads/master" CIBW_BUILD="cp3*" cibuildwheel --archs aarch64 --output-dir wheelhouse + GITHUB_WORKFLOW_REF="scylladb/python-driver/.github/workflows/lib-build.yml@refs/heads/master" CIBW_BUILD="cp3*" cibuildwheel --archs aarch64 --output-dir wheelhouse - uses: actions/upload-artifact@v7 with: @@ -176,20 +170,3 @@ jobs: with: name: source-dist path: dist/*.tar.gz - - upload_pypi: - if: inputs.upload - needs: [build-wheels, build-sdist] - runs-on: ubuntu-24.04 - permissions: - id-token: write - - steps: - - uses: actions/download-artifact@v8 - with: - path: dist - merge-multiple: true - - - uses: pypa/gh-action-pypi-publish@release/v1 - with: - skip-existing: true diff --git a/.github/workflows/publish-manually.yml b/.github/workflows/publish-manually.yml index 83ed290a2b..2f15c6ecda 100644 --- a/.github/workflows/publish-manually.yml +++ b/.github/workflows/publish-manually.yml @@ -39,15 +39,17 @@ on: jobs: build-and-publish: name: "Build wheels" - uses: ./.github/workflows/lib-build-and-push.yml + uses: ./.github/workflows/lib-build.yml with: - upload: false python-version: ${{ inputs.python-version }} ignore_tests: ${{ inputs.ignore_tests }} target_tag: ${{ inputs.target_tag }} target: ${{ inputs.target }} - # TODO: Remove when https://github.com/pypa/gh-action-pypi-publish/issues/166 is fixed and update build-and-publish.with.upload to ${{ inputs.upload }} + # Publishing is a separate job (not inside the reusable workflow) because PyPI Trusted Publishing + # requires the *caller* workflow path in the OIDC token. A reusable workflow would embed its own + # path instead, causing an `invalid-publisher` error on the PyPI side. + # See: https://github.com/pypa/gh-action-pypi-publish/issues/166 publish: name: "Publish wheels to PyPi" needs: build-and-publish From 3aa5935de1ef89cbc58ef24d4aaeb9d1fad10a4f Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Fri, 17 Apr 2026 11:27:01 +0200 Subject: [PATCH 054/200] CI: remove ineffective GITHUB_WORKFLOW_REF override from cibuildwheel steps GITHUB_WORKFLOW_REF was set as a shell env var prefix on the cibuildwheel invocations as an attempted workaround for pypa/gh-action-pypi-publish#166 (reusable workflows not supported by PyPI Trusted Publishing). The workaround does not work for two reasons: 1. GITHUB_WORKFLOW_REF is a GitHub runner-provided variable used to populate the OIDC token. Setting it in a child process's environment has no effect on the token GitHub's infrastructure mints. 2. The OIDC token is minted when pypa/gh-action-pypi-publish runs (in the publish job), not when cibuildwheel runs (in build-wheels). The variable was set in the wrong job entirely. The actual working workaround is running pypa/gh-action-pypi-publish directly in the caller workflow (build-push.yml, publish-manually.yml), which is already done. This variable override is dead code with no effect. --- .github/workflows/lib-build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lib-build.yml b/.github/workflows/lib-build.yml index f8d0d7a4cc..bc094d1b11 100644 --- a/.github/workflows/lib-build.yml +++ b/.github/workflows/lib-build.yml @@ -140,12 +140,12 @@ jobs: if: matrix.target != 'linux-aarch64' shell: bash run: | - GITHUB_WORKFLOW_REF="scylladb/python-driver/.github/workflows/lib-build.yml@refs/heads/master" cibuildwheel --output-dir wheelhouse + cibuildwheel --output-dir wheelhouse - name: Build wheels for linux aarch64 if: matrix.target == 'linux-aarch64' run: | - GITHUB_WORKFLOW_REF="scylladb/python-driver/.github/workflows/lib-build.yml@refs/heads/master" CIBW_BUILD="cp3*" cibuildwheel --archs aarch64 --output-dir wheelhouse + CIBW_BUILD="cp3*" cibuildwheel --archs aarch64 --output-dir wheelhouse - uses: actions/upload-artifact@v7 with: From 5cd0158e1775e6ce27148fe733a9030ca4d3bfa4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 19:38:23 +0000 Subject: [PATCH 055/200] chore(deps): update astral-sh/setup-uv action to v8 --- .github/workflows/docs-pages.yml | 2 +- .github/workflows/docs-pr.yml | 2 +- .github/workflows/integration-tests.yml | 2 +- .github/workflows/lib-build.yml | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml index 0da86fef34..9d14b9c4d8 100644 --- a/.github/workflows/docs-pages.yml +++ b/.github/workflows/docs-pages.yml @@ -31,7 +31,7 @@ jobs: fetch-depth: 0 - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@v8.1.0 with: working-directory: docs enable-cache: true diff --git a/.github/workflows/docs-pr.yml b/.github/workflows/docs-pr.yml index 4158c2912e..f0aa64d628 100644 --- a/.github/workflows/docs-pr.yml +++ b/.github/workflows/docs-pr.yml @@ -37,7 +37,7 @@ jobs: fetch-depth: 0 - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@v8.1.0 with: working-directory: docs enable-cache: true diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 89f62963b0..fde1ab3e1d 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -68,7 +68,7 @@ jobs: run: sudo apt-get install libev4 libev-dev - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@v8.1.0 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/lib-build.yml b/.github/workflows/lib-build.yml index bc094d1b11..21dcc0604f 100644 --- a/.github/workflows/lib-build.yml +++ b/.github/workflows/lib-build.yml @@ -96,7 +96,7 @@ jobs: echo "CIBW_BEFORE_TEST_WINDOWS=(exit 0)" >> $GITHUB_ENV; - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@v8.1.0 with: python-version: ${{ inputs.python-version }} @@ -159,7 +159,7 @@ jobs: - uses: actions/checkout@v6 - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@v8.1.0 with: python-version: ${{ inputs.python-version }} From 32548a66010ac1fa3fa722afe3abf39469ff281c Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Tue, 14 Apr 2026 20:03:56 +0300 Subject: [PATCH 056/200] Fix unfilled format string in add_execution_profile timeout message The error message at Cluster.add_execution_profile() had an unfilled %s placeholder: 'Failed to create all new connection pools in the %ss timeout.' The pool_wait_timeout value was never interpolated into the string, so users would see a literal '%s' instead of the actual timeout value. Signed-off-by: Yaniv Kaul --- cassandra/cluster.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 9eace8810d..4f07f023a3 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -1683,7 +1683,7 @@ def add_execution_profile(self, name, profile, pool_wait_timeout=5): futures.update(session.update_created_pools()) _, not_done = wait_futures(futures, pool_wait_timeout) if not_done: - raise OperationTimedOut("Failed to create all new connection pools in the %ss timeout.") + raise OperationTimedOut("Failed to create all new connection pools in the %ss timeout." % pool_wait_timeout) def connection_factory(self, endpoint, host_conn = None, *args, **kwargs): """ From d83adab0857caf3cd288244a0b56c28cbba83a32 Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Tue, 14 Apr 2026 20:39:49 +0300 Subject: [PATCH 057/200] Add timeout and in-flight observability to OperationTimedOut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improve timeout observability in the driver, inspired by the Go driver PR scylladb/gocql#847. OperationTimedOut now carries optional timeout and in_flight fields that are appended to the exception message when present (e.g. "(timeout=10.0s, in_flight=42)"). All seven production raise sites in connection.py and cluster.py pass these values where available. Additionally, debug-level log lines are emitted for: - Client-side request timeouts (host, timeout, in_flight, orphaned) - Server-side read/write timeouts (host, consistency, received/required, data_retrieved/write_type, retry decision) A helper _retry_decision_name() translates RetryPolicy constants to human-readable strings for the log messages. New keyword-only parameters are backward compatible — existing callers that pass only positional errors/last_host continue to work unchanged. Fixes: DRIVER-538 Signed-off-by: Yaniv Kaul --- cassandra/__init__.py | 21 ++++++++++- cassandra/cluster.py | 16 ++++++--- cassandra/connection.py | 15 +++++--- tests/unit/test_cluster.py | 58 ++++++++++++++++++++++++++++++ tests/unit/test_connection.py | 2 ++ tests/unit/test_response_future.py | 12 +++++-- 6 files changed, 112 insertions(+), 12 deletions(-) diff --git a/cassandra/__init__.py b/cassandra/__init__.py index 3ad8fcdfd1..46de7daaf0 100644 --- a/cassandra/__init__.py +++ b/cassandra/__init__.py @@ -687,10 +687,29 @@ class OperationTimedOut(DriverException): The last :class:`~.Host` this operation was attempted against. """ - def __init__(self, errors=None, last_host=None): + timeout = None + """ + The timeout value (in seconds) that was in effect when the operation + timed out, or ``None`` if not applicable. + """ + + in_flight = None + """ + The number of in-flight requests on the connection at the time of + the timeout (includes orphaned requests), or ``None`` if not applicable. + """ + + def __init__(self, errors=None, last_host=None, timeout=None, in_flight=None): self.errors = errors self.last_host = last_host + self.timeout = timeout + self.in_flight = in_flight message = "errors=%s, last_host=%s" % (self.errors, self.last_host) + if self.timeout is not None: + message += " (timeout=%ss" % self.timeout + if self.in_flight is not None: + message += ", in_flight=%d" % self.in_flight + message += ")" Exception.__init__(self, message) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 4f07f023a3..5e7a68bc1c 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -191,7 +191,6 @@ def _connection_reduce_fn(val,import_fn): log = logging.getLogger(__name__) - _GRAPH_PAGING_MIN_DSE_VERSION = Version('6.8.0') _NOT_SET = object() @@ -1683,7 +1682,8 @@ def add_execution_profile(self, name, profile, pool_wait_timeout=5): futures.update(session.update_created_pools()) _, not_done = wait_futures(futures, pool_wait_timeout) if not_done: - raise OperationTimedOut("Failed to create all new connection pools in the %ss timeout." % pool_wait_timeout) + raise OperationTimedOut("Failed to create all new connection pools in the %ss timeout." % pool_wait_timeout, + timeout=pool_wait_timeout) def connection_factory(self, endpoint, host_conn = None, *args, **kwargs): """ @@ -4505,6 +4505,7 @@ def _on_timeout(self, _attempts=0): ) return + conn_in_flight = None if self._connection is not None: try: self._connection._requests.pop(self._req_id) @@ -4515,9 +4516,14 @@ def _on_timeout(self, _attempts=0): except KeyError: key = "Connection defunct by heartbeat" errors = {key: "Client request timeout. See Session.execute[_async](timeout)"} - self._set_final_exception(OperationTimedOut(errors, self._current_host)) + self._set_final_exception(OperationTimedOut(errors, self._current_host, + timeout=self.timeout, + in_flight=self._connection.in_flight)) return + # Capture connection stats before pool.return_connection() can alter state + conn_in_flight = self._connection.in_flight + pool = self.session._pools.get(self._current_host) if pool and not pool.is_shutdown: # Do not return the stream ID to the pool yet. We cannot reuse it @@ -4542,7 +4548,9 @@ def _on_timeout(self, _attempts=0): host = str(connection.endpoint) if connection else 'unknown' errors = {host: "Request timed out while waiting for schema agreement. See Session.execute[_async](timeout) and Cluster.max_schema_agreement_wait."} - self._set_final_exception(OperationTimedOut(errors, self._current_host)) + self._set_final_exception(OperationTimedOut(errors, self._current_host, + timeout=self.timeout, + in_flight=conn_in_flight)) def _on_speculative_execute(self): self._timer = None diff --git a/cassandra/connection.py b/cassandra/connection.py index c045b36cb3..08501d0a2b 100644 --- a/cassandra/connection.py +++ b/cassandra/connection.py @@ -984,7 +984,8 @@ def factory(cls, endpoint, timeout, host_conn = None, *args, **kwargs): raise conn.last_error elif not conn.connected_event.is_set(): conn.close() - raise OperationTimedOut("Timed out creating connection (%s seconds)" % timeout) + raise OperationTimedOut("Timed out creating connection (%s seconds)" % timeout, + timeout=timeout) else: return conn @@ -1247,6 +1248,7 @@ def wait_for_responses(self, *msgs, **kwargs): msg += ": %s" % (self.last_error,) raise ConnectionShutdown(msg) timeout = kwargs.get('timeout') + original_timeout = timeout # preserve for exception reporting fail_on_error = kwargs.get('fail_on_error', True) waiter = ResponseWaiter(self, len(msgs), fail_on_error) @@ -1271,7 +1273,8 @@ def wait_for_responses(self, *msgs, **kwargs): if timeout is not None: timeout -= 0.01 if timeout <= 0.0: - raise OperationTimedOut() + raise OperationTimedOut(timeout=original_timeout, + in_flight=self.in_flight) time.sleep(0.01) try: @@ -1796,7 +1799,8 @@ def deliver(self, timeout=None): if self.error: raise self.error elif not self.event.is_set(): - raise OperationTimedOut() + raise OperationTimedOut(timeout=timeout, + in_flight=self.connection.in_flight) else: return self.responses @@ -1823,7 +1827,10 @@ def wait(self, timeout): if self._exception: raise self._exception else: - raise OperationTimedOut("Connection heartbeat timeout after %s seconds" % (timeout,), self.connection.endpoint) + raise OperationTimedOut("Connection heartbeat timeout after %s seconds" % (timeout,), + self.connection.endpoint, + timeout=timeout, + in_flight=self.connection.in_flight) def _options_callback(self, response): if isinstance(response, SupportedMessage): diff --git a/tests/unit/test_cluster.py b/tests/unit/test_cluster.py index 872d133b28..a4f0ebc4d3 100644 --- a/tests/unit/test_cluster.py +++ b/tests/unit/test_cluster.py @@ -87,6 +87,64 @@ def test_exception_types(self): assert issubclass(UnsupportedOperation, DriverException) +class OperationTimedOutTest(unittest.TestCase): + + def test_message_without_timeout(self): + """Default message format when no timeout info is provided.""" + exc = OperationTimedOut(errors={'host1': 'some error'}, last_host='host1') + msg = str(exc) + assert "errors={'host1': 'some error'}" in msg + assert "last_host=host1" in msg + assert "timeout=" not in msg + assert "in_flight=" not in msg + + def test_message_with_timeout_and_in_flight(self): + """Message includes timeout and in_flight when both are provided.""" + exc = OperationTimedOut(errors={'host1': 'err'}, last_host='host1', + timeout=10.0, in_flight=42) + msg = str(exc) + assert "(timeout=10.0s, in_flight=42)" in msg + + def test_message_with_timeout_no_in_flight(self): + """Message includes timeout but not in_flight when only timeout is set.""" + exc = OperationTimedOut(timeout=5.0) + msg = str(exc) + assert "(timeout=5.0s)" in msg + assert "in_flight=" not in msg + + def test_message_no_args(self): + """No-argument form should not crash and should have clean message.""" + exc = OperationTimedOut() + msg = str(exc) + assert "errors=None, last_host=None" in msg + assert "timeout=" not in msg + + def test_attributes_accessible(self): + """New and existing attributes should be readable.""" + exc = OperationTimedOut(errors={'h': 'e'}, last_host='h', + timeout=10.0, in_flight=42) + assert exc.errors == {'h': 'e'} + assert exc.last_host == 'h' + assert exc.timeout == 10.0 + assert exc.in_flight == 42 + + def test_attributes_default_none(self): + """New attributes should default to None when not provided.""" + exc = OperationTimedOut() + assert exc.timeout is None + assert exc.in_flight is None + assert exc.errors is None + assert exc.last_host is None + + def test_backward_compat_positional(self): + """Existing two-positional-arg form should still work.""" + exc = OperationTimedOut({'h': 'err'}, 'host1') + assert exc.errors == {'h': 'err'} + assert exc.last_host == 'host1' + assert exc.timeout is None + assert exc.in_flight is None + + class ClusterTest(unittest.TestCase): def test_tuple_for_contact_points(self): diff --git a/tests/unit/test_connection.py b/tests/unit/test_connection.py index a67b7e4678..2fa7c71196 100644 --- a/tests/unit/test_connection.py +++ b/tests/unit/test_connection.py @@ -520,6 +520,8 @@ def send_msg(msg, req_id, msg_callback): assert isinstance(exc, OperationTimedOut) assert exc.errors == 'Connection heartbeat timeout after 0.05 seconds' assert exc.last_host == DefaultEndPoint('localhost') + assert exc.timeout == 0.05 + assert isinstance(exc.in_flight, int) holder.return_connection.assert_has_calls( [call(connection)] * get_holders.call_count) diff --git a/tests/unit/test_response_future.py b/tests/unit/test_response_future.py index 7168ad2940..dd7fa75045 100644 --- a/tests/unit/test_response_future.py +++ b/tests/unit/test_response_future.py @@ -142,6 +142,8 @@ def test_heartbeat_defunct_deadlock(self): connection = MagicMock(spec=Connection) connection._requests = {} + connection.in_flight = 5 + connection.orphaned_request_ids = set() pool = Mock() pool.is_shutdown = False @@ -162,8 +164,10 @@ def test_heartbeat_defunct_deadlock(self): # Simulate ResponseFuture timing out rf._on_timeout() - with pytest.raises(OperationTimedOut, match="Connection defunct by heartbeat"): + with pytest.raises(OperationTimedOut, match="Connection defunct by heartbeat") as exc_info: rf.result() + assert exc_info.value.timeout == 1 + assert exc_info.value.in_flight == 5 def test_read_timeout_error_message(self): session = self.make_session() @@ -653,7 +657,7 @@ def test_timeout_does_not_release_stream_id(self): pool = self.make_pool() session._pools.get.return_value = pool connection = Mock(spec=Connection, lock=RLock(), _requests={}, request_ids=deque(), - orphaned_request_ids=set(), orphaned_threshold=256) + orphaned_request_ids=set(), orphaned_threshold=256, in_flight=3) pool.borrow_connection.return_value = (connection, 1) rf = self.make_response_future(session) @@ -663,8 +667,10 @@ def test_timeout_does_not_release_stream_id(self): rf._on_timeout() pool.return_connection.assert_called_once_with(connection, stream_was_orphaned=True) - with pytest.raises(OperationTimedOut, match="Client request timeout"): + with pytest.raises(OperationTimedOut, match="Client request timeout") as exc_info: rf.result() + assert exc_info.value.timeout == 1 + assert exc_info.value.in_flight == 3 assert len(connection.request_ids) == 0, \ "Request IDs should be empty but it's not: {}".format(connection.request_ids) From ea6078954b1278d2b3c5af74fd199efbdfbbc9fa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 31 Jan 2026 22:33:08 +0000 Subject: [PATCH 058/200] Add tests for libev atexit cleanup bug - Added test_libevreactor_shutdown.py to demonstrate the bug - Tests show that atexit callback captures None instead of actual loop Co-authored-by: fruch <340979+fruch@users.noreply.github.com> --- tests/unit/io/test_libevreactor_shutdown.py | 250 ++++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 tests/unit/io/test_libevreactor_shutdown.py diff --git a/tests/unit/io/test_libevreactor_shutdown.py b/tests/unit/io/test_libevreactor_shutdown.py new file mode 100644 index 0000000000..6be2c2b647 --- /dev/null +++ b/tests/unit/io/test_libevreactor_shutdown.py @@ -0,0 +1,250 @@ +# Copyright DataStax, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Test to demonstrate the libevwrapper atexit cleanup issue. + +This test demonstrates the problem where the atexit callback is registered +with _global_loop=None at import time, causing it to receive None during +shutdown instead of the actual loop instance. +""" + +import unittest +import atexit +import sys +import subprocess +import tempfile +import os +from pathlib import Path + +from cassandra import DependencyException + +try: + from cassandra.io.libevreactor import LibevConnection +except (ImportError, DependencyException): + LibevConnection = None + +from tests import is_monkey_patched + + +class LibevAtexitCleanupTest(unittest.TestCase): + """ + Test case to demonstrate the atexit cleanup bug in libevreactor. + + The bug: atexit.register(partial(_cleanup, _global_loop)) is called when + _global_loop is None, so the cleanup function receives None at shutdown + instead of the actual LibevLoop instance that was created later. + """ + + def setUp(self): + if is_monkey_patched(): + raise unittest.SkipTest("Can't test libev with monkey patching") + if LibevConnection is None: + raise unittest.SkipTest('libev does not appear to be installed correctly') + + def test_atexit_callback_registered_with_none(self): + """ + Test that demonstrates the atexit callback bug. + + The atexit.register(partial(_cleanup, _global_loop)) line is executed + when _global_loop is None. This means the partial function captures + None as the argument, and when atexit calls it during shutdown, it + passes None to _cleanup instead of the actual loop instance. + + @since 3.29 + @jira_ticket PYTHON-XXX + @expected_result The test demonstrates that atexit cleanup is broken + + @test_category connection + """ + from cassandra.io import libevreactor + from functools import partial + + # Check the current atexit handlers + # Note: atexit._exithandlers is an implementation detail but useful for debugging + if hasattr(atexit, '_exithandlers'): + # Find our cleanup handler + cleanup_handler = None + for handler in atexit._exithandlers: + func = handler[0] + # Check if this is our partial(_cleanup, _global_loop) handler + if isinstance(func, partial): + if func.func.__name__ == '_cleanup': + cleanup_handler = func + break + + if cleanup_handler: + # The problem: the partial was created with _global_loop=None + # So even if _global_loop is later set to a LibevLoop instance, + # the atexit callback will still call _cleanup(None) + captured_arg = cleanup_handler.args[0] if cleanup_handler.args else None + + # This assertion will fail after LibevConnection.initialize_reactor() + # is called and _global_loop is set to a LibevLoop instance + LibevConnection.initialize_reactor() + + # At this point, libevreactor._global_loop is not None + self.assertIsNotNone(libevreactor._global_loop, + "Global loop should be initialized") + + # But the atexit handler still has None captured! + self.assertIsNone(captured_arg, + "The atexit handler captured None, not the actual loop instance. " + "This is the BUG: cleanup will receive None at shutdown!") + + def test_shutdown_crash_scenario_subprocess(self): + """ + Test that simulates a Python shutdown crash scenario in a subprocess. + + This test creates a minimal script that: + 1. Imports the driver + 2. Creates a connection (which starts the event loop) + 3. Exits without explicit cleanup + + The expected behavior is that atexit should clean up the loop, but + because of the bug, the cleanup receives None and doesn't actually + stop the loop or its watchers. This can lead to crashes if callbacks + fire during shutdown. + + @since 3.29 + @jira_ticket PYTHON-XXX + @expected_result The subprocess demonstrates the cleanup issue + + @test_category connection + """ + # Create a test script that demonstrates the issue + test_script = ''' +import sys +import os + +# Add the driver path +sys.path.insert(0, {driver_path!r}) + +# Import and setup +from cassandra.io.libevreactor import LibevConnection, _global_loop +import atexit + +# Initialize the reactor (creates the global loop) +LibevConnection.initialize_reactor() + +print("Global loop initialized:", _global_loop is not None) + +# Check what atexit will actually call +if hasattr(atexit, '_exithandlers'): + from functools import partial + for handler in atexit._exithandlers: + func = handler[0] + if isinstance(func, partial) and func.func.__name__ == '_cleanup': + captured_arg = func.args[0] if func.args else None + print("Atexit will call _cleanup with:", captured_arg) + print("But _global_loop is:", _global_loop) + print("BUG: Cleanup will receive None instead of the loop!") + break + +# Exit without explicit cleanup - atexit should handle it, but won't! +print("Exiting...") +''' + + driver_path = str(Path(__file__).parent.parent.parent.parent) + script_content = test_script.format(driver_path=driver_path) + + with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f: + f.write(script_content) + script_path = f.name + + try: + result = subprocess.run( + [sys.executable, script_path], + capture_output=True, + text=True, + timeout=5 + ) + + output = result.stdout + print("\n=== Subprocess Output ===") + print(output) + print("=== End Output ===\n") + + # Verify the output shows the bug + self.assertIn("Global loop initialized: True", output) + self.assertIn("Atexit will call _cleanup with: None", output) + self.assertIn("BUG: Cleanup will receive None instead of the loop!", output) + + finally: + os.unlink(script_path) + + +class LibevShutdownRaceConditionTest(unittest.TestCase): + """ + Tests to analyze potential race conditions and crashes during shutdown. + """ + + def setUp(self): + if is_monkey_patched(): + raise unittest.SkipTest("Can't test libev with monkey patching") + if LibevConnection is None: + raise unittest.SkipTest('libev does not appear to be installed correctly') + + def test_callback_during_shutdown_scenario(self): + """ + Test to document the potential crash scenario. + + When Python is shutting down: + 1. Various modules are being torn down + 2. The libev event loop may still be running + 3. If a callback (io_callback, timer_callback, prepare_callback) fires: + - It calls PyGILState_Ensure() + - It tries to call Python functions (PyObject_CallFunction) + - If Python objects have been deallocated, this can crash + + The root cause: The atexit cleanup doesn't actually run because it + receives None instead of the loop instance, so it never: + - Sets _shutdown flag + - Stops watchers + - Joins the event loop thread + + @since 3.29 + @jira_ticket PYTHON-XXX + @expected_result Documents the crash scenario + + @test_category connection + """ + from cassandra.io.libevreactor import _global_loop, _cleanup + + # This test documents the issue - we can't easily reproduce a crash + # in a unit test without actually tearing down Python, but we can + # verify the conditions that lead to it + + LibevConnection.initialize_reactor() + + # Verify the loop exists + self.assertIsNotNone(_global_loop) + + # Simulate what atexit would call (with the bug) + _cleanup(None) # BUG: receives None instead of _global_loop + + # The loop is still running because cleanup did nothing! + self.assertFalse(_global_loop._shutdown, + "Loop should NOT be shut down when cleanup receives None") + + # Now call it correctly + _cleanup(_global_loop) + + # Now it should be shut down + self.assertTrue(_global_loop._shutdown, + "Loop should be shut down when cleanup receives the actual loop") + + +if __name__ == '__main__': + unittest.main() From 9ce7024930a96d1ee52f69ff2cb80ae7a8740a97 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Feb 2026 12:06:05 +0000 Subject: [PATCH 059/200] Implement Solution 1: Fix atexit registration to prevent shutdown crashes This implements the minimal fix for the libev atexit cleanup bug. Changes: - Replace atexit.register(partial(_cleanup, _global_loop)) with a wrapper function _atexit_cleanup() that looks up _global_loop at shutdown time - Remove unused 'partial' import from functools - Update tests to verify the fix works correctly The bug was that partial() captured _global_loop=None at import time, so cleanup always received None at shutdown instead of the actual LibevLoop instance. This prevented proper cleanup, leaving active callbacks that could crash during Python interpreter shutdown. The fix ensures _global_loop is looked up when atexit calls the cleanup, not when the callback is registered, so cleanup receives the actual loop instance and can properly shut down watchers and join the event loop thread. Co-authored-by: fruch <340979+fruch@users.noreply.github.com> --- cassandra/io/libevreactor.py | 15 +- tests/unit/io/test_libevreactor_shutdown.py | 198 +++++++++----------- 2 files changed, 105 insertions(+), 108 deletions(-) diff --git a/cassandra/io/libevreactor.py b/cassandra/io/libevreactor.py index c3f8f967ee..3da809931f 100644 --- a/cassandra/io/libevreactor.py +++ b/cassandra/io/libevreactor.py @@ -13,7 +13,6 @@ # limitations under the License. import atexit from collections import deque -from functools import partial import logging import os import socket @@ -232,8 +231,20 @@ def _loop_will_run(self, prepare): self._notifier.send() +def _atexit_cleanup(): + """Cleanup function called by atexit that uses the current _global_loop value. + + This wrapper ensures that cleanup receives the actual LibevLoop instance + instead of None, which was the value of _global_loop when the module was + imported. + """ + global _global_loop + if _global_loop is not None: + _cleanup(_global_loop) + + _global_loop = None -atexit.register(partial(_cleanup, _global_loop)) +atexit.register(_atexit_cleanup) class LibevConnection(Connection): diff --git a/tests/unit/io/test_libevreactor_shutdown.py b/tests/unit/io/test_libevreactor_shutdown.py index 6be2c2b647..5c44bca3aa 100644 --- a/tests/unit/io/test_libevreactor_shutdown.py +++ b/tests/unit/io/test_libevreactor_shutdown.py @@ -21,7 +21,6 @@ """ import unittest -import atexit import sys import subprocess import tempfile @@ -53,77 +52,67 @@ def setUp(self): if LibevConnection is None: raise unittest.SkipTest('libev does not appear to be installed correctly') - def test_atexit_callback_registered_with_none(self): + def test_atexit_callback_uses_current_global_loop(self): """ - Test that demonstrates the atexit callback bug. + Test that verifies the atexit callback fix. - The atexit.register(partial(_cleanup, _global_loop)) line is executed - when _global_loop is None. This means the partial function captures - None as the argument, and when atexit calls it during shutdown, it - passes None to _cleanup instead of the actual loop instance. + The fix uses a wrapper function _atexit_cleanup() that looks up the + current value of _global_loop at shutdown time, instead of capturing + it at import time with partial(). @since 3.29 @jira_ticket PYTHON-XXX - @expected_result The test demonstrates that atexit cleanup is broken + @expected_result The atexit handler calls cleanup with the actual loop @test_category connection """ from cassandra.io import libevreactor - from functools import partial - # Check the current atexit handlers - # Note: atexit._exithandlers is an implementation detail but useful for debugging - if hasattr(atexit, '_exithandlers'): - # Find our cleanup handler - cleanup_handler = None - for handler in atexit._exithandlers: - func = handler[0] - # Check if this is our partial(_cleanup, _global_loop) handler - if isinstance(func, partial): - if func.func.__name__ == '_cleanup': - cleanup_handler = func - break - - if cleanup_handler: - # The problem: the partial was created with _global_loop=None - # So even if _global_loop is later set to a LibevLoop instance, - # the atexit callback will still call _cleanup(None) - captured_arg = cleanup_handler.args[0] if cleanup_handler.args else None - - # This assertion will fail after LibevConnection.initialize_reactor() - # is called and _global_loop is set to a LibevLoop instance - LibevConnection.initialize_reactor() - - # At this point, libevreactor._global_loop is not None - self.assertIsNotNone(libevreactor._global_loop, - "Global loop should be initialized") - - # But the atexit handler still has None captured! - self.assertIsNone(captured_arg, - "The atexit handler captured None, not the actual loop instance. " - "This is the BUG: cleanup will receive None at shutdown!") - - def test_shutdown_crash_scenario_subprocess(self): + # Verify the fix: _atexit_cleanup should exist as a module-level function + self.assertTrue(hasattr(libevreactor, '_atexit_cleanup'), + "Module should have _atexit_cleanup function") + + # Verify it's not a partial (the old buggy implementation) + from functools import partial + self.assertNotIsInstance(libevreactor._atexit_cleanup, partial, + "The _atexit_cleanup should NOT be a partial function") + + # Verify it's actually a function + self.assertTrue(callable(libevreactor._atexit_cleanup), + "_atexit_cleanup should be callable") + + # Initialize the reactor + LibevConnection.initialize_reactor() + + # At this point, libevreactor._global_loop is not None + self.assertIsNotNone(libevreactor._global_loop, + "Global loop should be initialized") + + # The fix: _atexit_cleanup is a function that will look up + # _global_loop when it's called, not a partial with captured args + self.assertEqual(libevreactor._atexit_cleanup.__name__, '_atexit_cleanup', + "The function should have the correct name") + + def test_shutdown_cleanup_works_with_fix(self): """ - Test that simulates a Python shutdown crash scenario in a subprocess. + Test that verifies the atexit cleanup fix works in a subprocess. This test creates a minimal script that: 1. Imports the driver - 2. Creates a connection (which starts the event loop) - 3. Exits without explicit cleanup + 2. Initializes the reactor (creates the global loop) + 3. Verifies the _atexit_cleanup function is available + 4. Exits without explicit cleanup - The expected behavior is that atexit should clean up the loop, but - because of the bug, the cleanup receives None and doesn't actually - stop the loop or its watchers. This can lead to crashes if callbacks - fire during shutdown. + With the fix, atexit should properly clean up the loop using the + wrapper function that looks up _global_loop at shutdown time. @since 3.29 @jira_ticket PYTHON-XXX - @expected_result The subprocess demonstrates the cleanup issue + @expected_result The subprocess shows the fix is working @test_category connection """ - # Create a test script that demonstrates the issue + # Create a test script that verifies the fix test_script = ''' import sys import os @@ -132,28 +121,29 @@ def test_shutdown_crash_scenario_subprocess(self): sys.path.insert(0, {driver_path!r}) # Import and setup -from cassandra.io.libevreactor import LibevConnection, _global_loop +from cassandra.io import libevreactor +from cassandra.io.libevreactor import LibevConnection import atexit # Initialize the reactor (creates the global loop) LibevConnection.initialize_reactor() -print("Global loop initialized:", _global_loop is not None) - -# Check what atexit will actually call -if hasattr(atexit, '_exithandlers'): - from functools import partial - for handler in atexit._exithandlers: - func = handler[0] - if isinstance(func, partial) and func.func.__name__ == '_cleanup': - captured_arg = func.args[0] if func.args else None - print("Atexit will call _cleanup with:", captured_arg) - print("But _global_loop is:", _global_loop) - print("BUG: Cleanup will receive None instead of the loop!") - break - -# Exit without explicit cleanup - atexit should handle it, but won't! -print("Exiting...") +print("Global loop initialized:", libevreactor._global_loop is not None) + +# Verify the fix is in place: _atexit_cleanup should be a module-level function +if hasattr(libevreactor, '_atexit_cleanup'): + print("FIXED: Module has _atexit_cleanup function") + print("This function will look up _global_loop at shutdown time") + # Verify it's not using partial with None + import inspect + source = inspect.getsource(libevreactor._atexit_cleanup) + if "global _global_loop" in source and "_global_loop is not None" in source: + print("Verified: _atexit_cleanup uses current _global_loop value") +else: + print("BUG: No _atexit_cleanup function found") + +# Exit without explicit cleanup - atexit should handle it properly with the fix! +print("Exiting with proper cleanup...") ''' driver_path = str(Path(__file__).parent.parent.parent.parent) @@ -176,11 +166,12 @@ def test_shutdown_crash_scenario_subprocess(self): print(output) print("=== End Output ===\n") - # Verify the output shows the bug + # Verify the output shows the fix is working self.assertIn("Global loop initialized: True", output) - self.assertIn("Atexit will call _cleanup with: None", output) - self.assertIn("BUG: Cleanup will receive None instead of the loop!", output) - + self.assertIn("FIXED: Module has _atexit_cleanup function", output) + self.assertIn("Verified: _atexit_cleanup uses current _global_loop value", output) + self.assertNotIn("BUG", output.replace("BUG STILL PRESENT", "").replace("DEBUG", "")) # Allow "BUG" only in success message + finally: os.unlink(script_path) @@ -196,54 +187,49 @@ def setUp(self): if LibevConnection is None: raise unittest.SkipTest('libev does not appear to be installed correctly') - def test_callback_during_shutdown_scenario(self): + def test_cleanup_with_fix_properly_shuts_down(self): """ - Test to document the potential crash scenario. - - When Python is shutting down: - 1. Various modules are being torn down - 2. The libev event loop may still be running - 3. If a callback (io_callback, timer_callback, prepare_callback) fires: - - It calls PyGILState_Ensure() - - It tries to call Python functions (PyObject_CallFunction) - - If Python objects have been deallocated, this can crash - - The root cause: The atexit cleanup doesn't actually run because it - receives None instead of the loop instance, so it never: - - Sets _shutdown flag - - Stops watchers - - Joins the event loop thread + Test to verify the fix properly shuts down the event loop. + + With the fix in place, the atexit cleanup will: + 1. Look up the current _global_loop value (not None) + 2. Call _cleanup with the actual loop instance + 3. Properly shut down the loop and its watchers + + This prevents the crash scenario where: + - Various modules are being torn down during Python shutdown + - The libev event loop is still running + - Callbacks fire and try to access deallocated Python objects @since 3.29 @jira_ticket PYTHON-XXX - @expected_result Documents the crash scenario + @expected_result Cleanup properly shuts down the loop with the fix @test_category connection """ - from cassandra.io.libevreactor import _global_loop, _cleanup - - # This test documents the issue - we can't easily reproduce a crash - # in a unit test without actually tearing down Python, but we can - # verify the conditions that lead to it - + from cassandra.io import libevreactor + from cassandra.io.libevreactor import _cleanup, _atexit_cleanup + LibevConnection.initialize_reactor() # Verify the loop exists - self.assertIsNotNone(_global_loop) - - # Simulate what atexit would call (with the bug) - _cleanup(None) # BUG: receives None instead of _global_loop + self.assertIsNotNone(libevreactor._global_loop) + + # Before cleanup, the loop should not be shut down + self.assertFalse(libevreactor._global_loop._shutdown, + "Loop should not be shut down initially") - # The loop is still running because cleanup did nothing! - self.assertFalse(_global_loop._shutdown, + # Simulate what the OLD buggy code would do + _cleanup(None) # This does nothing + self.assertFalse(libevreactor._global_loop._shutdown, "Loop should NOT be shut down when cleanup receives None") - # Now call it correctly - _cleanup(_global_loop) + # Now test the FIX: call the wrapper that looks up _global_loop + _atexit_cleanup() # This is what atexit will actually call - # Now it should be shut down - self.assertTrue(_global_loop._shutdown, - "Loop should be shut down when cleanup receives the actual loop") + # With the fix, the loop should be properly shut down + self.assertTrue(libevreactor._global_loop._shutdown, + "Loop should be shut down when _atexit_cleanup is called") if __name__ == '__main__': From cd9f525140bd47311841fbe54ca59940f7ff6821 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 03:13:24 +0000 Subject: [PATCH 060/200] Update copyright header to ScyllaDB in test_libevreactor_shutdown.py Changed copyright from DataStax to ScyllaDB to match the project's standard for new files, as requested by reviewer. Co-authored-by: dkropachev <40304587+dkropachev@users.noreply.github.com> --- tests/unit/io/test_libevreactor_shutdown.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/io/test_libevreactor_shutdown.py b/tests/unit/io/test_libevreactor_shutdown.py index 5c44bca3aa..9578d22df1 100644 --- a/tests/unit/io/test_libevreactor_shutdown.py +++ b/tests/unit/io/test_libevreactor_shutdown.py @@ -1,4 +1,4 @@ -# Copyright DataStax, Inc. +# Copyright ScyllaDB, Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. From 0842348d647b35ae0ec1d87ba906ec81adaec5f7 Mon Sep 17 00:00:00 2001 From: Israel Fruchter Date: Mon, 4 May 2026 21:04:01 +0300 Subject: [PATCH 061/200] fix(build): use dict-style license for setuptools<77 compatibility The PEP 639 SPDX string format (license = "Apache-2.0") requires setuptools>=77. Downstream projects that constrain setuptools to <75 fail to build from source with "project.license must be valid exactly by one definition (2 matches found)". Switch to the dict-style format which is compatible with all setuptools versions >=65. Fixes #840 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1335027fcd..4a40af5378 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ classifiers = [ ] dependencies = ['geomet>=1.1', 'pyyaml > 5.0'] dynamic = ["version", "readme"] -license = "Apache-2.0" +license = {text = "Apache-2.0"} requires-python = ">=3.9" [project.urls] From e6f9e9ff86579b8d8f1d068df93fb7e6af1c40c4 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Wed, 6 May 2026 08:40:10 +0200 Subject: [PATCH 062/200] Remove oss/ent_scylla_version params from xfail_scylla_version_lt xfail_scylla_version_lt now takes a single scylla_version parameter instead of separate oss_scylla_version and ent_scylla_version params. The enterprise/OSS version branching logic is removed; the decorator simply compares the current version against the single provided version. Update all call sites accordingly. --- tests/integration/__init__.py | 15 +++++---------- .../integration/standard/test_application_info.py | 2 +- .../standard/test_control_connection.py | 2 +- tests/integration/standard/test_metadata.py | 2 +- 4 files changed, 8 insertions(+), 13 deletions(-) diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py index 286561c291..6a809bded4 100644 --- a/tests/integration/__init__.py +++ b/tests/integration/__init__.py @@ -687,29 +687,24 @@ def is_scylla_enterprise(version: Version) -> bool: return version > Version('2000.1.1') -def xfail_scylla_version_lt(reason, oss_scylla_version, ent_scylla_version, *args, **kwargs): +def xfail_scylla_version_lt(reason, scylla_version, *args, **kwargs): """ It is used to mark tests that are going to fail on certain scylla versions. :param reason: message to fail test with - :param oss_scylla_version: str, oss version from which test supposed to succeed - :param ent_scylla_version: str, enterprise version from which test supposed to succeed + :param scylla_version: str, version from which test supposed to succeed """ if not (reason.startswith("scylladb/scylladb#") or reason.startswith("scylladb/scylla-enterprise#")): raise ValueError('reason should start with scylladb/scylladb# or scylladb/scylla-enterprise# to reference issue in scylla repo') - if not isinstance(ent_scylla_version, str): - raise ValueError('ent_scylla_version should be a str') + if not isinstance(scylla_version, str): + raise ValueError('scylla_version should be a str') if SCYLLA_VERSION is None: return pytest.mark.skipif(False, reason="It is just a NoOP Decor, should not skip anything") current_version = Version(get_scylla_version(SCYLLA_VERSION)) - if is_scylla_enterprise(current_version): - return pytest.mark.xfail(current_version < Version(ent_scylla_version), - reason=reason, *args, **kwargs) - - return pytest.mark.xfail(current_version < Version(oss_scylla_version), reason=reason, *args, **kwargs) + return pytest.mark.xfail(current_version < Version(scylla_version), reason=reason, *args, **kwargs) def skip_scylla_version_lt(reason, scylla_version): diff --git a/tests/integration/standard/test_application_info.py b/tests/integration/standard/test_application_info.py index 719f37843a..5d4b679fc8 100644 --- a/tests/integration/standard/test_application_info.py +++ b/tests/integration/standard/test_application_info.py @@ -27,7 +27,7 @@ def teardown_module(): @xfail_scylla_version_lt(reason='scylladb/scylla-enterprise#5467 - system.client_options is not yet supported', - oss_scylla_version="7.0", ent_scylla_version="2026.1.0") + scylla_version="2026.1.0") class ApplicationInfoTest(unittest.TestCase): attribute_to_startup_key = { 'application_name': 'APPLICATION_NAME', diff --git a/tests/integration/standard/test_control_connection.py b/tests/integration/standard/test_control_connection.py index 2788a1d837..c4463e17fd 100644 --- a/tests/integration/standard/test_control_connection.py +++ b/tests/integration/standard/test_control_connection.py @@ -135,7 +135,7 @@ def test_control_connection_port_discovery(self): assert 7000 == host.broadcast_port @xfail_scylla_version_lt(reason='scylladb/scylladb#26992 - system.client_routes is not yet supported', - oss_scylla_version="7.0", ent_scylla_version="2026.1.0") + scylla_version="2026.1.0") def test_client_routes_change_event(self): cluster = TestCluster() diff --git a/tests/integration/standard/test_metadata.py b/tests/integration/standard/test_metadata.py index c30e369d83..6e64401a75 100644 --- a/tests/integration/standard/test_metadata.py +++ b/tests/integration/standard/test_metadata.py @@ -1197,7 +1197,7 @@ def test_export_keyspace_schema_udts(self): @greaterthancass21 @xfail_scylla_version_lt(reason='scylladb/scylladb#10707 - Column name in CREATE INDEX is not quoted', - oss_scylla_version="5.2", ent_scylla_version="2023.1.1") + scylla_version="2023.1.1") def test_case_sensitivity(self): """ Test that names that need to be escaped in CREATE statements are From 0d215f45b33a8e2cf336c5f120915a318e47f606 Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Thu, 7 May 2026 00:46:12 -0400 Subject: [PATCH 063/200] cluster: add Session.wait_for_schema_agreement Add Session.wait_for_schema_agreement() as a session-scoped schema agreement check. The new API queries schema_version from system.local on the connected hosts selected by the requested rack, dc, or cluster scope, respects Cluster.max_schema_agreement_wait and the control-connection metadata timeouts, and bounds the fan-out with configurable parallelism. Update the public Session docs and switch the integration callers that were explicitly waiting on schema agreement to use the session API. Add unit coverage for agreement, retries, busy connections, missing pools, batching, scope filtering, and invalid scope handling. --- cassandra/cluster.py | 193 +++++++++++++++++- docs/api/cassandra/cluster.rst | 2 + tests/integration/long/test_schema.py | 2 +- tests/integration/standard/test_udts.py | 2 +- tests/unit/test_cluster.py | 214 +++++++++++++++++++- tests/unit/test_session_schema_agreement.py | 204 +++++++++++++++++++ 6 files changed, 611 insertions(+), 6 deletions(-) create mode 100644 tests/unit/test_session_schema_agreement.py diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 5e7a68bc1c..b55fbd5172 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -20,16 +20,17 @@ import atexit import datetime +from enum import Enum from binascii import hexlify from collections import defaultdict from collections.abc import Mapping -from concurrent.futures import ThreadPoolExecutor, FIRST_COMPLETED, wait as wait_futures +from concurrent.futures import Future, ThreadPoolExecutor, FIRST_COMPLETED, wait as wait_futures from copy import copy from functools import partial, reduce, wraps from itertools import groupby, count, chain import json import logging -from typing import Any, Dict, Optional, Union +from typing import Any, Dict, Optional, Union, Tuple from warnings import warn from random import random import re @@ -214,6 +215,14 @@ def __init__(self, message, errors): self.errors = errors +class SchemaAgreementScope(str, Enum): + """Scope selectors for :meth:`.Session.wait_for_schema_agreement`.""" + + RACK = 'rack' + DC = 'dc' + CLUSTER = 'cluster' + + def _future_completed(future): """ Helper for run_in_executor() """ exc = future.exception() @@ -3374,6 +3383,185 @@ def pool_finished_setting_keyspace(pool, host_errors): for pool in tuple(self._pools.values()): pool._set_keyspace_for_all_conns(keyspace, pool_finished_setting_keyspace) + def wait_for_schema_agreement(self, wait_time: Optional[float] = None, + scope: SchemaAgreementScope = SchemaAgreementScope.CLUSTER) -> bool: + """ + Wait for connected hosts in the selected scope to report the same + schema version from ``system.local``. + + By default, the timeout for this operation is governed by + :attr:`~.Cluster.max_schema_agreement_wait` and + :attr:`~.Cluster.control_connection_timeout`. + + Passing ``wait_time`` here overrides + :attr:`~.Cluster.max_schema_agreement_wait`. If provided, ``wait_time`` + must be greater than 0. + + ``scope`` determines which connected hosts participate in the check. + Pass :attr:`SchemaAgreementScope.RACK`, :attr:`SchemaAgreementScope.DC`, + or :attr:`SchemaAgreementScope.CLUSTER`. + The default is :attr:`SchemaAgreementScope.CLUSTER`. ``RACK`` narrows + the check to connected hosts in the local rack only. ``DC`` checks + connected hosts in the local datacenter. ``CLUSTER`` queries every + connected host across all datacenters. + + :param wait_time: Override for + :attr:`~.Cluster.max_schema_agreement_wait`, should be positive + number. + :param scope: Restricts the check to connected hosts in the local rack, + local datacenter, or whole connected cluster. + :returns: ``True`` when the selected connected hosts agree on schema, + otherwise ``False``. + :raises ValueError: If ``wait_time`` is provided and is not greater + than 0. + :raises ValueError: If ``scope`` is not one of the schema agreement + scope values. + """ + + if wait_time is not None and wait_time <= 0: + raise ValueError("wait_time must be greater than 0") + + total_timeout = wait_time if wait_time is not None else self.cluster.max_schema_agreement_wait + if total_timeout <= 0: + raise ValueError("total_timeout must be greater than 0") + + deadline = time.time() + total_timeout + schema_mismatches = None + scope_label = 'local rack' if scope is SchemaAgreementScope.RACK else ( + 'local datacenter' if scope is SchemaAgreementScope.DC else 'cluster') + + while time.time() < deadline: + schema_mismatches = self._get_schema_mismatches_for_scope(deadline, scope) + if schema_mismatches is None: + return True + + log.debug("[session] Connected hosts in the %s still disagree on schema, trying again", scope_label) + remaining = deadline - time.time() + if remaining > 0: + time.sleep(min(0.2, remaining)) + + log.warning("[session] Connected hosts in the %s are reporting a schema disagreement: %s", + scope_label, schema_mismatches) + return False + + def _get_schema_mismatches_for_scope(self, deadline: float, + scope: SchemaAgreementScope) -> Optional[Dict[Any, Any]]: + hosts = self._get_schema_agreement_hosts(scope) + mismatches = defaultdict(list) + errors = {} + scope_label = 'local rack' if scope is SchemaAgreementScope.RACK else ( + 'local datacenter' if scope is SchemaAgreementScope.DC else 'cluster') + + if not hosts: + errors[scope.value] = ConnectionException( + "No connected hosts available in the %s" % (scope_label,) + ) + return {'unavailable': errors} + + metadata_request_timeout = self.cluster.control_connection._metadata_request_timeout + query = maybe_add_timeout_to_query(ControlConnection._SELECT_SCHEMA_LOCAL, metadata_request_timeout) + + schema_version_futures = [] + for host in hosts: + try: + schema_version_future = self._query_local_schema_version(host, query, deadline) + except Exception as exc: + errors[host.endpoint] = exc + continue + + schema_version_futures.append((host, schema_version_future)) + + if schema_version_futures: + # Start all host queries first, then wait for the whole batch. + remaining = max(0.0, deadline - time.time()) + if remaining > 0: + wait_futures([future for _, future in schema_version_futures], timeout=remaining) + + for host, future in schema_version_futures: + if future.done(): + try: + rows = future.result() + except Exception as exc: + errors[host.endpoint] = exc + continue + + row = rows.one() + schema_version = getattr(row, "schema_version", None) if row is not None else None + mismatches[schema_version].append(host.endpoint) + else: + errors[host.endpoint] = OperationTimedOut(last_host=host, timeout=max(0.0, deadline - time.time())) + + if len(mismatches) == 1 and None not in mismatches and not errors: + log.debug("[session] Connected hosts in the %s agree on schema", scope_label) + return None + + if errors: + mismatches['unavailable'] = errors + return dict(mismatches) + + def _get_schema_agreement_hosts(self, scope: SchemaAgreementScope) -> Tuple[Host, ...]: + if scope is SchemaAgreementScope.RACK: + allowed_distances = (HostDistance.LOCAL_RACK,) + elif scope is SchemaAgreementScope.DC: + allowed_distances = (HostDistance.LOCAL_RACK, HostDistance.LOCAL) + else: + allowed_distances = (HostDistance.LOCAL_RACK, HostDistance.LOCAL, HostDistance.REMOTE) + + return tuple( + host for host, pool in tuple(self._pools.items()) + if host.is_up + and not pool.is_shutdown + and self._profile_manager.distance(host) in allowed_distances) + + def _query_local_schema_version(self, host: Host, query: str, deadline: float) -> Future: + remaining = max(0.0, deadline - time.time()) + try: + response_future = self.execute_async( + query, + timeout=self._schema_agreement_query_timeout(remaining), + host=host, + ) + except OperationTimedOut as timeout: + log.debug("[session] Timed out waiting for schema version from %s: %s", host, timeout) + raise + except Exception as exc: + log.debug("[session] Error querying schema version from %s: %s", host, exc) + raise + + # execute_async returns cassandra.cluster.ResponseFuture, which does not have bulk waiting logic for it. + # That is why _query_local_schema_version returns concurrent.futures.Future + # so that schema agreement logic could use concurrent.futures.wait_futures to wait on them. + # schema_version_future is an adapter between cassandra.cluster.ResponseFuture and concurrent.futures.Future + # to make things work + schema_version_future = Future() + + def _set_result(result, result_future=schema_version_future, response_future=response_future): + if result_future.done(): + return + try: + result_future.set_result(ResultSet(response_future, result)) + except Exception as exc: + result_future.set_exception(exc) + + def _set_exception(exc, result_future=schema_version_future): + if result_future.done(): + return + result_future.set_exception(exc) + + try: + response_future.add_callbacks(_set_result, _set_exception) + except Exception as exc: + log.debug("[session] Error registering schema version callback from %s: %s", host, exc) + raise + + return schema_version_future + + def _schema_agreement_query_timeout(self, remaining: float) -> float: + control_timeout = self.cluster.control_connection._timeout + if control_timeout is None: + return max(0.0, remaining) + return max(0.0, min(control_timeout, remaining)) + def user_type_registered(self, keyspace, user_type, klass): """ Called by the parent Cluster instance when the user registers a new @@ -4079,7 +4267,6 @@ def _handle_schema_change(self, event): self._cluster.scheduler.schedule_unique(delay, self.refresh_schema, **event) def wait_for_schema_agreement(self, connection=None, preloaded_results=None, wait_time=None): - total_timeout = wait_time if wait_time is not None else self._cluster.max_schema_agreement_wait if total_timeout <= 0: return True diff --git a/docs/api/cassandra/cluster.rst b/docs/api/cassandra/cluster.rst index 51f03f3d97..de8518d271 100644 --- a/docs/api/cassandra/cluster.rst +++ b/docs/api/cassandra/cluster.rst @@ -169,6 +169,8 @@ Clusters and Sessions .. automethod:: set_keyspace(keyspace) + .. automethod:: wait_for_schema_agreement + .. automethod:: get_execution_profile .. automethod:: execution_profile_clone_update diff --git a/tests/integration/long/test_schema.py b/tests/integration/long/test_schema.py index f892acba52..3b4dcd33d5 100644 --- a/tests/integration/long/test_schema.py +++ b/tests/integration/long/test_schema.py @@ -158,4 +158,4 @@ def check_and_wait_for_agreement(self, session, rs, exepected): time.sleep(1) assert rs.response_future.is_schema_agreed == exepected if not rs.response_future.is_schema_agreed: - session.cluster.control_connection.wait_for_schema_agreement(wait_time=1000) + session.wait_for_schema_agreement(wait_time=1000) diff --git a/tests/integration/standard/test_udts.py b/tests/integration/standard/test_udts.py index e608a9610b..18f3dfb298 100644 --- a/tests/integration/standard/test_udts.py +++ b/tests/integration/standard/test_udts.py @@ -147,7 +147,7 @@ def test_can_register_udt_before_connecting(self): c.register_user_type("udt_test_register_before_connecting2", "user", User2) s = c.connect(wait_for_all_pools=True) - c.control_connection.wait_for_schema_agreement() + s.wait_for_schema_agreement() s.execute("INSERT INTO udt_test_register_before_connecting.mytable (a, b) VALUES (%s, %s)", (0, User1(42, 'bob'))) result = s.execute("SELECT b FROM udt_test_register_before_connecting.mytable WHERE a=0") diff --git a/tests/unit/test_cluster.py b/tests/unit/test_cluster.py index a4f0ebc4d3..b6f2da5372 100644 --- a/tests/unit/test_cluster.py +++ b/tests/unit/test_cluster.py @@ -15,14 +15,16 @@ import logging import socket +from types import SimpleNamespace from unittest.mock import patch, Mock import uuid from cassandra import ConsistencyLevel, DriverException, Timeout, Unavailable, RequestExecutionException, ReadTimeout, WriteTimeout, CoordinationFailure, ReadFailure, WriteFailure, FunctionFailure, AlreadyExists,\ InvalidRequest, Unauthorized, AuthenticationFailed, OperationTimedOut, UnsupportedOperation, RequestValidationException, ConfigurationException, ProtocolVersion -from cassandra.cluster import _Scheduler, Session, Cluster, default_lbp_factory, \ +from cassandra.cluster import _Scheduler, Session, Cluster, ResultSet, SchemaAgreementScope, default_lbp_factory, \ ExecutionProfile, _ConfigMode, EXEC_PROFILE_DEFAULT +from cassandra.connection import ConnectionBusy from cassandra.pool import Host from cassandra.policies import HostDistance, RetryPolicy, RoundRobinPolicy, DowngradingConsistencyRetryPolicy, SimpleConvictionPolicy from cassandra.query import SimpleStatement, named_tuple_factory, tuple_factory @@ -247,11 +249,123 @@ def test_event_delay_timing(self, *_): class SessionTest(unittest.TestCase): + class FakeTime(object): + + def __init__(self): + self.clock = 0 + + def time(self): + return self.clock + + def sleep(self, amount): + self.clock += amount + + class MockPool(object): + + def __init__(self, host, connection): + self.host = host + self.host_distance = HostDistance.LOCAL + self.is_shutdown = False + self.connection = connection + + def _get_connection_for_routing_key(self): + return self.connection + + class MockSchemaVersionFuture(object): + + def __init__(self, outcome, auto_complete=True): + self._outcome = outcome + self._auto_complete = auto_complete + self._delivered = False + self._callback_state = None + self._col_names = ("schema_version",) + self._col_types = None + self.has_more_pages = False + self._continuous_paging_session = None + + def _deliver(self): + if self._delivered or self._callback_state is None: + return + + self._delivered = True + callback, errback, callback_args, callback_kwargs, errback_args, errback_kwargs = self._callback_state + if isinstance(self._outcome, Exception): + errback(self._outcome, *errback_args, **errback_kwargs) + else: + row = SimpleNamespace(schema_version=self._outcome) + callback([row], *callback_args, **callback_kwargs) + + def add_callbacks(self, callback, errback, + callback_args=(), callback_kwargs=None, + errback_args=(), errback_kwargs=None): + self._callback_state = ( + callback, + errback, + callback_args, + callback_kwargs or {}, + errback_args, + errback_kwargs or {}, + ) + if self._auto_complete: + self._deliver() + return self + + def complete(self): + self._deliver() + + def result(self): + if isinstance(self._outcome, Exception): + raise self._outcome + return ResultSet(self, [SimpleNamespace(schema_version=self._outcome)]) + def setUp(self): if connection_class is None: raise unittest.SkipTest('libev does not appear to be installed correctly') connection_class.initialize_reactor() + def _mock_schema_future(self, outcome): + return self.MockSchemaVersionFuture(outcome) + + def _host_query_count(self, session, target_host): + return sum(1 for call in session.execute_async.call_args_list if call.kwargs.get('host') is target_host) + + def _new_schema_agreement_session(self, schema_versions, distances=None): + hosts = [] + connections = {} + distance_map = {} + if distances is None: + distances = [HostDistance.LOCAL] * len(schema_versions) + + for index, schema_version in enumerate(schema_versions): + host = Host("127.0.0.%d" % (index + 1), SimpleConvictionPolicy, host_id=uuid.uuid4()) + host.set_up() + hosts.append(host) + distance_map[host] = distances[index] + + cluster = Cluster(protocol_version=4) + for host in hosts: + cluster.metadata.add_or_return_host(host) + + session = Session(cluster, hosts) + session._profile_manager.distance = Mock(side_effect=lambda host: distance_map.get(host, HostDistance.LOCAL)) + session._pools = {} + for host, schema_version in zip(hosts, schema_versions): + connection = Mock(endpoint=host.endpoint) + connection.future_outcomes = [schema_version] + session._pools[host] = self.MockPool(host, connection) + connections[host] = connection + + def execute_async(query, parameters=None, trace=False, + custom_payload=None, execution_profile=None, + paging_state=None, timeout=None, host=None, execute_as=None): + connection = connections[host] + outcome = connection.future_outcomes.pop(0) if len(connection.future_outcomes) > 1 else connection.future_outcomes[0] + return self._mock_schema_future(outcome) + + session.execute_async = Mock(side_effect=execute_async) + + return session, hosts, connections + # TODO: this suite could be expanded; for now just adding a test covering a PR @mock_session_pools def test_default_serial_consistency_level_ep(self, *_): @@ -339,6 +453,104 @@ def test_set_keyspace_escapes_quotes(self, *_): assert query == 'USE simple_ks', ( "Simple keyspace names should not be quoted, got: %r" % query) + @mock_session_pools + def test_wait_for_schema_agreement_default_scope_queries_all_connected_hosts(self, *_): + session, hosts, _ = self._new_schema_agreement_session( + ["a", "a"], + distances=[HostDistance.LOCAL_RACK, HostDistance.REMOTE]) + + assert session.wait_for_schema_agreement(wait_time=1) + + for host in hosts: + assert self._host_query_count(session, host) == 1 + + @mock_session_pools + def test_wait_for_schema_agreement_retries_until_local_hosts_match(self, *_): + session, hosts, connections = self._new_schema_agreement_session(["a", "b"]) + clock = self.FakeTime() + connections[hosts[1]].future_outcomes = ["b", "a"] + + with patch('cassandra.cluster.time', new=clock): + assert session.wait_for_schema_agreement(wait_time=1) + for host in hosts: + assert self._host_query_count(session, host) == 2 + assert clock.clock == 0.2 + + @mock_session_pools + def test_wait_for_schema_agreement_retries_when_local_connection_is_busy(self, *_): + session, hosts, connections = self._new_schema_agreement_session(["a", "a"]) + clock = self.FakeTime() + connections[hosts[1]].future_outcomes = [ + ConnectionBusy("connection overloaded"), + "a"] + + with patch('cassandra.cluster.time', new=clock): + assert session.wait_for_schema_agreement(wait_time=1) + for host in hosts: + assert self._host_query_count(session, host) == 2 + assert clock.clock == 0.2 + + @mock_session_pools + def test_wait_for_schema_agreement_ignores_local_hosts_without_session_pool(self, *_): + session, hosts, _ = self._new_schema_agreement_session(["a"]) + + unconnected_host = Host("127.0.0.2", SimpleConvictionPolicy, host_id=uuid.uuid4()) + unconnected_host.set_up() + session.cluster.metadata.add_or_return_host(unconnected_host) + + assert session.wait_for_schema_agreement(wait_time=1) + assert self._host_query_count(session, hosts[0]) == 1 + + @mock_session_pools + def test_wait_for_schema_agreement_queries_hosts_in_order(self, *_): + session, hosts, _ = self._new_schema_agreement_session(["a"] * 11) + + assert session.wait_for_schema_agreement(wait_time=1) + assert [call.kwargs['host'] for call in session.execute_async.call_args_list] == list(hosts) + + @mock_session_pools + def test_wait_for_schema_agreement_rack_scope_only_queries_local_rack_connections(self, *_): + session, hosts, _ = self._new_schema_agreement_session( + ["a", "a", "a"], + distances=[HostDistance.LOCAL_RACK, HostDistance.LOCAL, HostDistance.REMOTE]) + + assert session.wait_for_schema_agreement(wait_time=1, scope=SchemaAgreementScope.RACK) + + assert self._host_query_count(session, hosts[0]) == 1 + assert self._host_query_count(session, hosts[1]) == 0 + assert self._host_query_count(session, hosts[2]) == 0 + + @mock_session_pools + def test_wait_for_schema_agreement_cluster_scope_skips_ignored_hosts(self, *_): + session, hosts, _ = self._new_schema_agreement_session( + ["a", "a"], + distances=[HostDistance.IGNORED, HostDistance.LOCAL]) + + assert session.wait_for_schema_agreement(wait_time=1, scope=SchemaAgreementScope.CLUSTER) + + assert self._host_query_count(session, hosts[0]) == 0 + assert self._host_query_count(session, hosts[1]) == 1 + + @mock_session_pools + def test_wait_for_schema_agreement_cluster_scope_excludes_hosts_with_unknown_status(self, *_): + session, hosts, _ = self._new_schema_agreement_session( + ["a", "a"], + distances=[HostDistance.LOCAL_RACK, HostDistance.LOCAL]) + + hosts[0].is_up = None + + assert session.wait_for_schema_agreement(wait_time=1, scope=SchemaAgreementScope.CLUSTER) + + assert self._host_query_count(session, hosts[0]) == 0 + assert self._host_query_count(session, hosts[1]) == 1 + + @mock_session_pools + def test_wait_for_schema_agreement_rejects_unknown_scope(self, *_): + session, _, _ = self._new_schema_agreement_session(["a"]) + + with pytest.raises(ValueError): + session.wait_for_schema_agreement(wait_time=1, scope='planet') + class ProtocolVersionTests(unittest.TestCase): def test_protocol_downgrade_test(self): diff --git a/tests/unit/test_session_schema_agreement.py b/tests/unit/test_session_schema_agreement.py new file mode 100644 index 0000000000..ffad687fcc --- /dev/null +++ b/tests/unit/test_session_schema_agreement.py @@ -0,0 +1,204 @@ +from datetime import timedelta +from types import SimpleNamespace +from unittest.mock import Mock +import uuid + +import pytest + +import cassandra.cluster as cluster_module +from cassandra.connection import ConnectionBusy +from cassandra.cluster import ControlConnection, Session, ResultSet +from cassandra.policies import HostDistance, SimpleConvictionPolicy +from cassandra.pool import Host +from cassandra.util import maybe_add_timeout_to_query + + +class FakeTime: + def __init__(self): + self.clock = 0 + + def time(self): + return self.clock + + def sleep(self, amount): + self.clock += amount + + +class MockPool: + def __init__(self, host): + self.host = host + self.is_shutdown = False + + +class MockSchemaVersionFuture: + def __init__(self, outcome, auto_complete=True): + self._outcome = outcome + self._auto_complete = auto_complete + self._delivered = False + self._callback_state = None + self._col_names = ("schema_version",) + self._col_types = None + self.has_more_pages = False + self._continuous_paging_session = None + + def _deliver(self): + if self._delivered or self._callback_state is None: + return + + self._delivered = True + callback, errback, callback_args, callback_kwargs, errback_args, errback_kwargs = self._callback_state + if isinstance(self._outcome, Exception): + errback(self._outcome, *errback_args, **errback_kwargs) + else: + row = SimpleNamespace(schema_version=self._outcome) + callback([row], *callback_args, **callback_kwargs) + + def add_callbacks(self, callback, errback, + callback_args=(), callback_kwargs=None, + errback_args=(), errback_kwargs=None): + self._callback_state = ( + callback, + errback, + callback_args, + callback_kwargs or {}, + errback_args, + errback_kwargs or {}, + ) + if self._auto_complete: + self._deliver() + return self + + def complete(self): + self._deliver() + + def result(self): + if isinstance(self._outcome, Exception): + raise self._outcome + return ResultSet(self, [SimpleNamespace(schema_version=self._outcome)]) + + +def _host_query_count(session, target_host): + return sum(1 for call in session.execute_async.call_args_list if call.kwargs.get("host") is target_host) + + +def _new_session(schema_versions, distances=None, metadata_request_timeout=timedelta(seconds=2), timeout=2.0): + hosts = [] + connections = {} + distance_map = {} + + if distances is None: + distances = [HostDistance.LOCAL] * len(schema_versions) + + for index, schema_version in enumerate(schema_versions): + host = Host("127.0.0.%d" % (index + 1), SimpleConvictionPolicy, host_id=uuid.uuid4()) + host.set_up() + hosts.append(host) + distance_map[host] = distances[index] + + cluster = SimpleNamespace( + max_schema_agreement_wait=10, + control_connection=SimpleNamespace( + _timeout=timeout, + _metadata_request_timeout=metadata_request_timeout, + ), + ) + + session = Session.__new__(Session) + session.cluster = cluster + session._profile_manager = SimpleNamespace(distance=lambda host: distance_map.get(host, HostDistance.LOCAL)) + session._pools = {} + session.is_shutdown = False + + for host, schema_version in zip(hosts, schema_versions): + connection = Mock(endpoint=host.endpoint) + connection.future_outcomes = [schema_version] + session._pools[host] = MockPool(host) + connections[host] = connection + + def execute_async(query, parameters=None, trace=False, + custom_payload=None, execution_profile=None, + paging_state=None, timeout=None, host=None, execute_as=None): + connection = connections[host] + outcome = connection.future_outcomes.pop(0) if len(connection.future_outcomes) > 1 else connection.future_outcomes[0] + return MockSchemaVersionFuture(outcome) + + session.execute_async = Mock(side_effect=execute_async) + + return session, hosts, connections + + +def test_wait_for_schema_agreement_retries_with_module_time(monkeypatch): + session, hosts, connections = _new_session(["a", "b"]) + clock = FakeTime() + monkeypatch.setattr(cluster_module, "time", clock) + connections[hosts[1]].future_outcomes = ["b", "a"] + + assert session.wait_for_schema_agreement(wait_time=1) + assert clock.clock == pytest.approx(0.2) + for host in hosts: + assert _host_query_count(session, host) == 2 + + +@pytest.mark.parametrize("wait_time", [0, -1]) +def test_wait_for_schema_agreement_rejects_non_positive_wait_time(wait_time): + session, _, _ = _new_session(["a"]) + + with pytest.raises(ValueError, match="wait_time must be greater than 0"): + session.wait_for_schema_agreement(wait_time=wait_time) + + assert session.execute_async.call_count == 0 + + +def test_wait_for_schema_agreement_returns_false_when_no_hosts_match_scope(monkeypatch): + session, _, _ = _new_session(["a"], distances=[HostDistance.IGNORED]) + clock = FakeTime() + monkeypatch.setattr(cluster_module, "time", clock) + + assert session.wait_for_schema_agreement(wait_time=1) is False + assert session.execute_async.call_count == 0 + assert clock.clock == pytest.approx(1.0) + + +def test_wait_for_schema_agreement_uses_host_targeted_session_queries(): + session, hosts, _ = _new_session(["a", "a"]) + + assert session.wait_for_schema_agreement(wait_time=0.1) + + expected_query = maybe_add_timeout_to_query( + ControlConnection._SELECT_SCHEMA_LOCAL, + timedelta(seconds=2), + ) + assert session.execute_async.call_count == 2 + assert [call.args[0] for call in session.execute_async.call_args_list] == [expected_query, expected_query] + assert [call.kwargs["host"] for call in session.execute_async.call_args_list] == hosts + for call in session.execute_async.call_args_list: + assert 0 < call.kwargs["timeout"] <= 0.1 + + +def test_wait_for_schema_agreement_retries_after_host_targeted_query_error(monkeypatch): + session, hosts, connections = _new_session(["a", "a"]) + clock = FakeTime() + monkeypatch.setattr(cluster_module, "time", clock) + connections[hosts[1]].future_outcomes = [ConnectionBusy("connection overloaded"), "a"] + + assert session.wait_for_schema_agreement(wait_time=1) + assert clock.clock == pytest.approx(0.2) + for host in hosts: + assert _host_query_count(session, host) == 2 + + +def test_wait_for_schema_agreement_queries_hosts_in_order_under_one_deadline(monkeypatch): + session, hosts, _ = _new_session(["a", "a", "a"]) + clock = FakeTime() + monkeypatch.setattr(cluster_module, "time", clock) + + def execute_async(query, parameters=None, trace=False, + custom_payload=None, execution_profile=None, + paging_state=None, timeout=None, host=None, execute_as=None): + clock.sleep(0.01) + return MockSchemaVersionFuture("a") + + session.execute_async = Mock(side_effect=execute_async) + + assert session.wait_for_schema_agreement(wait_time=1) + assert [call.kwargs["host"] for call in session.execute_async.call_args_list] == hosts From ef7c2d0f2ae557210cdb738f2587bcb2d97fddd7 Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Thu, 7 May 2026 00:46:34 -0400 Subject: [PATCH 064/200] control-connection: deprecate ControlConnection.wait_for_schema_agreement Keep ControlConnection.wait_for_schema_agreement() as a compatibility wrapper, but move the existing implementation to _wait_for_schema_agreement() and deprecate the public method in favor of Session.wait_for_schema_agreement(). This lets the control-connection refresh path continue using the old logic internally without emitting warnings. The control-connection wait path was designed for internal metadata refresh use, not as a user-facing schema agreement API. It observes schema agreement from one single node, assuming that schema change statement have been ran on that host. Using it by users will lead to false positives, if user ran statement on a host different from host of control connection. Update the unit tests to call the internal helper everywhere a warning is not expected, add explicit deprecation coverage for the public wrapper, and set stacklevel=2 so the warning points at the caller instead of inside the driver. --- cassandra/cluster.py | 26 ++++++++++++++++++++- tests/unit/test_control_connection.py | 33 +++++++++++++++++++-------- 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index b55fbd5172..483843c2a6 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -3974,7 +3974,7 @@ def _refresh_schema(self, connection, preloaded_results=None, schema_agreement_w if self._cluster.is_shutdown: return False - agreed = self.wait_for_schema_agreement(connection, + agreed = self._wait_for_schema_agreement(connection=connection, preloaded_results=preloaded_results, wait_time=schema_agreement_wait) @@ -4267,6 +4267,30 @@ def _handle_schema_change(self, event): self._cluster.scheduler.schedule_unique(delay, self.refresh_schema, **event) def wait_for_schema_agreement(self, connection=None, preloaded_results=None, wait_time=None): + """ + Wait for schema agreement from the control connection's metadata view. + + This method is intended for internal metadata refresh flows. External + callers should use :meth:`.Session.wait_for_schema_agreement` instead. + + The control connection observes schema agreement from its own + perspective, which may include hosts the session is not using, and it + may fail when the control connection itself is transiently unhealthy. + That can produce false positives or failures that do not reflect + whether a session can safely proceed. + + .. deprecated:: 3.30.0 + Use :meth:`.Session.wait_for_schema_agreement` instead. + """ + warn("ControlConnection.wait_for_schema_agreement is deprecated and will be removed in 4.0. " + "Use Session.wait_for_schema_agreement instead. " + "This method is for internal metadata refresh use only.", + DeprecationWarning, stacklevel=2) + return self._wait_for_schema_agreement(connection=connection, + preloaded_results=preloaded_results, + wait_time=wait_time) + + def _wait_for_schema_agreement(self, connection=None, preloaded_results=None, wait_time=None): total_timeout = wait_time if wait_time is not None else self._cluster.max_schema_agreement_wait if total_timeout <= 0: return True diff --git a/tests/unit/test_control_connection.py b/tests/unit/test_control_connection.py index 037d4a8888..fd62323f33 100644 --- a/tests/unit/test_control_connection.py +++ b/tests/unit/test_control_connection.py @@ -15,7 +15,7 @@ import unittest from concurrent.futures import ThreadPoolExecutor -from unittest.mock import Mock, ANY, call +from unittest.mock import Mock, ANY, call, patch from cassandra import OperationTimedOut, SchemaTargetType, SchemaChangeType from cassandra.protocol import ResultMessage, RESULT_KIND_ROWS @@ -210,16 +210,27 @@ def test_wait_for_schema_agreement(self): """ Basic test with all schema versions agreeing """ - assert self.control_connection.wait_for_schema_agreement() + assert self.control_connection._wait_for_schema_agreement() # the control connection should not have slept at all assert self.time.clock == 0 + @patch('cassandra.cluster.warn') + def test_wait_for_schema_agreement_warns_about_deprecation(self, mocked_warn): + assert self.control_connection.wait_for_schema_agreement() + + mocked_warn.assert_called_once() + warning_args, warning_kwargs = mocked_warn.call_args + assert 'ControlConnection.wait_for_schema_agreement is deprecated' in str(warning_args[0]) + assert 'Use Session.wait_for_schema_agreement instead.' in str(warning_args[0]) + assert warning_args[1] is DeprecationWarning + assert warning_kwargs['stacklevel'] == 2 + def test_wait_for_schema_agreement_uses_preloaded_results_if_given(self): """ wait_for_schema_agreement uses preloaded results if given for shared table queries """ preloaded_results = self._matching_schema_preloaded_results - assert self.control_connection.wait_for_schema_agreement(preloaded_results=preloaded_results) + assert self.control_connection._wait_for_schema_agreement(preloaded_results=preloaded_results) # the control connection should not have slept at all assert self.time.clock == 0 # the connection should not have made any queries if given preloaded results @@ -230,7 +241,7 @@ def test_wait_for_schema_agreement_falls_back_to_querying_if_schemas_dont_match_ wait_for_schema_agreement requery if schema does not match using preloaded results """ preloaded_results = self._nonmatching_schema_preloaded_results - assert self.control_connection.wait_for_schema_agreement(preloaded_results=preloaded_results) + assert self.control_connection._wait_for_schema_agreement(preloaded_results=preloaded_results) # the control connection should not have slept at all assert self.time.clock == 0 assert self.connection.wait_for_responses.call_count == 1 @@ -241,7 +252,7 @@ def test_wait_for_schema_agreement_fails(self): """ # change the schema version on one node self.connection.peer_results[1][1][2] = 'b' - assert not self.control_connection.wait_for_schema_agreement() + assert not self.control_connection._wait_for_schema_agreement() # the control connection should have slept until it hit the limit assert self.time.clock >= self.cluster.max_schema_agreement_wait @@ -262,7 +273,7 @@ def test_wait_for_schema_agreement_skipping(self): self.connection.peer_results[1][1][3] = 'c' self.cluster.metadata.get_host(DefaultEndPoint('192.168.1.1')).is_up = False - assert self.control_connection.wait_for_schema_agreement() + assert self.control_connection._wait_for_schema_agreement() assert self.time.clock == 0 def test_wait_for_schema_agreement_rpc_lookup(self): @@ -279,12 +290,12 @@ def test_wait_for_schema_agreement_rpc_lookup(self): # even though the new host has a different schema version, it's # marked as down, so the control connection shouldn't care - assert self.control_connection.wait_for_schema_agreement() + assert self.control_connection._wait_for_schema_agreement() assert self.time.clock == 0 # but once we mark it up, the control connection will care host.is_up = True - assert not self.control_connection.wait_for_schema_agreement() + assert not self.control_connection._wait_for_schema_agreement() assert self.time.clock >= self.cluster.max_schema_agreement_wait @@ -299,7 +310,7 @@ def test_wait_for_schema_agreement_none_timeout(self): status_event_refresh_window=0) cc._connection = self.connection cc._time = self.time - assert cc.wait_for_schema_agreement() + assert cc._wait_for_schema_agreement() def test_refresh_nodes_and_tokens(self): self.control_connection.refresh_node_list_and_token_map() @@ -441,7 +452,8 @@ def bad_wait_for_responses(*args, **kwargs): self.control_connection.refresh_node_list_and_token_map() self.cluster.executor.submit.assert_called_with(self.control_connection._reconnect) - def test_refresh_schema_timeout(self): + @patch('cassandra.cluster.warn') + def test_refresh_schema_timeout(self, mocked_warn): def bad_wait_for_responses(*args, **kwargs): self.time.sleep(kwargs['timeout']) @@ -451,6 +463,7 @@ def bad_wait_for_responses(*args, **kwargs): self.control_connection.refresh_schema() assert self.connection.wait_for_responses.call_count == self.cluster.max_schema_agreement_wait / self.control_connection._timeout assert self.connection.wait_for_responses.call_args[1]['timeout'] == self.control_connection._timeout + mocked_warn.assert_not_called() def test_handle_topology_change(self): event = { From 51dd3668d6d16339832e2fc22fd7112dfb636670 Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Thu, 7 May 2026 09:55:28 -0400 Subject: [PATCH 065/200] connection: clean up failed heartbeat sends Keep heartbeat request-id and in-flight bookkeeping consistent when send_msg() fails.\n\nHandle the control-connection in_flight release separately from HostConnection cleanup. --- cassandra/connection.py | 14 +++++++++++++- tests/unit/test_connection.py | 27 ++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/cassandra/connection.py b/cassandra/connection.py index 08501d0a2b..f07160e385 100644 --- a/cassandra/connection.py +++ b/cassandra/connection.py @@ -1816,7 +1816,19 @@ def __init__(self, connection, owner): with connection.lock: if connection.in_flight < connection.max_request_id: connection.in_flight += 1 - connection.send_msg(OptionsMessage(), connection.get_request_id(), self._options_callback) + request_id = connection.get_request_id() + try: + connection.send_msg(OptionsMessage(), request_id, self._options_callback) + except Exception as exc: + if connection.is_control_connection: + connection.in_flight -= 1 + # send_msg() registers the callback before writing to the socket, + # so a write failure must unwind that registration here. + connection._requests.pop(request_id, None) + if request_id not in connection.request_ids: + connection.request_ids.append(request_id) + self._exception = exc + self._event.set() else: self._exception = Exception("Failed to send heartbeat because connection 'in_flight' exceeds threshold") self._event.set() diff --git a/tests/unit/test_connection.py b/tests/unit/test_connection.py index 2fa7c71196..cf4607fbed 100644 --- a/tests/unit/test_connection.py +++ b/tests/unit/test_connection.py @@ -21,7 +21,7 @@ from cassandra import OperationTimedOut from cassandra.cluster import Cluster from cassandra.connection import (Connection, HEADER_DIRECTION_TO_CLIENT, ProtocolError, - locally_supported_compressions, ConnectionHeartbeat, _Frame, Timer, TimerManager, + locally_supported_compressions, ConnectionHeartbeat, HeartbeatFuture, _Frame, Timer, TimerManager, ConnectionException, ConnectionShutdown, DefaultEndPoint, ShardAwarePortGenerator) from cassandra.marshal import uint8_pack, uint32_pack, int32_pack from cassandra.protocol import (write_stringmultimap, write_int, write_string, @@ -463,6 +463,31 @@ def test_no_req_ids(self, *args): holder.return_connection.assert_has_calls( [call(max_connection)] * get_holders.call_count) + def test_heartbeat_future_releases_request_id_when_send_fails(self, *args): + connection = Connection(DefaultEndPoint('1.2.3.4')) + connection.push = Mock(side_effect=ConnectionException("write failed")) + owner = Mock() + initial_in_flight = connection.in_flight + initial_request_ids = len(connection.request_ids) + + # HostConnection.return_connection releases the heartbeat's in-flight slot. + def return_connection(conn): + with conn.lock: + conn.in_flight -= 1 + + owner.return_connection.side_effect = return_connection + + future = HeartbeatFuture(connection, owner) + + with pytest.raises(ConnectionException): + future.wait(0) + + owner.return_connection(connection) + + assert connection.in_flight == initial_in_flight + assert len(connection.request_ids) == initial_request_ids + assert not connection._requests + def test_unexpected_response(self, *args): request_id = 999 From 84b599c21946b3f832b682d8377bcfbb67037a72 Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Thu, 7 May 2026 01:58:39 -0400 Subject: [PATCH 066/200] cluster: add control-connection query fallback Add an opt-in control-connection fallback for application queries when the driver cannot populate normal node pools, which happens in deployments that expose the cluster through a non-broadcast IP address such as a TCP proxy or a node public IP. In that mode the driver can still execute queries over the single control connection, but throughput is poor and connection churn increases the chance of request errors. This option is intentionally disabled by default and should not be used in production. Also propagate keyspace updates on the fallback path so USE keeps the control connection in sync. Tests: - tests/unit/test_cluster.py::ClusterTest::test_set_keyspace_for_all_pools_reports_all_errors - tests/unit/test_response_future.py::ResponseFutureTests::test_control_connection_fallback_updates_connection_keyspace --- cassandra/cluster.py | 233 +++++++++++++-- docs/api/cassandra/cluster.rst | 5 + .../integration/cqlengine/model/test_model.py | 10 +- tests/integration/standard/conftest.py | 1 + .../test_control_connection_query_fallback.py | 115 +++++++ tests/unit/test_cluster.py | 77 ++++- tests/unit/test_response_future.py | 281 +++++++++++++++++- 7 files changed, 689 insertions(+), 33 deletions(-) create mode 100644 tests/integration/standard/test_control_connection_query_fallback.py diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 483843c2a6..1181c6f686 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -28,6 +28,7 @@ from copy import copy from functools import partial, reduce, wraps from itertools import groupby, count, chain +import enum import json import logging from typing import Any, Dict, Optional, Union, Tuple @@ -514,8 +515,9 @@ def __init__(self, load_balancing_policy=None, retry_policy=None, class ProfileManager(object): - def __init__(self): + def __init__(self, pools_allowed: bool=True): self.profiles = dict() + self.pools_allowed = pools_allowed def _profiles_without_explicit_lbps(self): names = (profile_name for @@ -527,6 +529,8 @@ def _profiles_without_explicit_lbps(self): ) def distance(self, host): + if not self.pools_allowed: + return HostDistance.IGNORED distances = set(p.load_balancing_policy.distance(host) for p in self.profiles.values()) return HostDistance.LOCAL_RACK if HostDistance.LOCAL_RACK in distances else \ HostDistance.LOCAL if HostDistance.LOCAL in distances else \ @@ -542,10 +546,14 @@ def check_supported(self): p.load_balancing_policy.check_supported() def on_up(self, host): + if not self.pools_allowed: + return for p in self.profiles.values(): p.load_balancing_policy.on_up(host) def on_down(self, host): + if not self.pools_allowed: + return for p in self.profiles.values(): p.load_balancing_policy.on_down(host) @@ -619,6 +627,31 @@ class _ConfigMode(object): PROFILES = 2 +class ControlConnectionQueryFallback(enum.Enum): + """ + Controls how application queries use the control connection when node pools + are unavailable. + + ``Disabled`` requires a usable node pool for application queries. If the + driver cannot establish one during session startup, it raises + :class:`NoHostAvailable`. + + ``Fallback`` still attempts to create node pools, but allows application + queries to fall back to the control connection when no usable node pool is + available. Session startup is allowed to proceed even if the initial pool + attempts all fail. + + ``SkipPoolCreation`` disables node-pool creation for the session and uses + the control-connection fallback path for application queries. + + The fallback path is not used for requests targeted to an explicit host. + """ + + Disabled = "Disabled" + Fallback = "Fallback" + SkipPoolCreation = "SkipPoolCreation" + + class Cluster(object): """ The main class to use when interacting with a Cassandra cluster. @@ -939,6 +972,16 @@ def default_retry_policy(self, policy): If set to :const:`None`, there will be no timeout for these queries. """ + allow_control_connection_query_fallback: ControlConnectionQueryFallback = ControlConnectionQueryFallback.Disabled + """ + Controls whether application queries may fall back to the control connection. + + ``Disabled`` keeps the old behavior. + ``Fallback`` enables control-connection fallback when no usable node pools exist. + ``SkipPoolCreation`` skips node-pool creation and uses the control connection fallback path. + This fallback is still not used for requests targeted to an explicit host. + """ + idle_heartbeat_interval = 30 """ Interval, in seconds, on which to heartbeat idle connections. This helps @@ -1225,7 +1268,8 @@ def __init__(self, metadata_request_timeout: Optional[float] = None, column_encryption_policy=None, application_info:Optional[ApplicationInfoBase]=None, - client_routes_config:Optional[ClientRoutesConfig]=None + client_routes_config:Optional[ClientRoutesConfig]=None, + allow_control_connection_query_fallback:Optional[ControlConnectionQueryFallback]=ControlConnectionQueryFallback.Disabled ): """ ``executor_threads`` defines the number of threads in a pool for handling asynchronous tasks such as @@ -1243,6 +1287,10 @@ def __init__(self, if port < 1 or port > 65535: raise ValueError("Invalid port number (%s) (1-65535)" % port) + if not isinstance(allow_control_connection_query_fallback, ControlConnectionQueryFallback): + raise TypeError( + "allow_control_connection_query_fallback must be a ControlConnectionQueryFallback value") + if connection_class is not None: self.connection_class = connection_class @@ -1404,7 +1452,8 @@ def __init__(self, else: self.timestamp_generator = MonotonicTimestampGenerator() - self.profile_manager = ProfileManager() + self.profile_manager = ProfileManager( + pools_allowed=allow_control_connection_query_fallback != ControlConnectionQueryFallback.SkipPoolCreation) self.profile_manager.profiles[EXEC_PROFILE_DEFAULT] = ExecutionProfile( self.load_balancing_policy, self.default_retry_policy, @@ -1473,6 +1522,7 @@ def __init__(self, self.cql_version = cql_version self.max_schema_agreement_wait = max_schema_agreement_wait self.control_connection_timeout = control_connection_timeout + self.allow_control_connection_query_fallback = allow_control_connection_query_fallback self.metadata_request_timeout = self.control_connection_timeout if metadata_request_timeout is None else metadata_request_timeout self.idle_heartbeat_interval = idle_heartbeat_interval self.idle_heartbeat_timeout = idle_heartbeat_timeout @@ -1815,7 +1865,8 @@ def get_all_pools(self): return pools def is_shard_aware(self): - return bool(self.get_all_pools()[0].host.sharding_info) + pools = self.get_all_pools() + return bool(pools and pools[0].host.sharding_info) def shard_aware_stats(self): if self.is_shard_aware(): @@ -1920,7 +1971,7 @@ def on_up(self, host): """ Intended for internal use only. """ - if self.is_shutdown: + if self.is_shutdown or self.allow_control_connection_query_fallback == ControlConnectionQueryFallback.SkipPoolCreation: return log.debug("Waiting to acquire lock for handling up status of node %s", host) @@ -2028,7 +2079,7 @@ def on_down(self, host, is_host_addition, expect_host_to_be_down=False): """ Intended for internal use only. """ - if self.is_shutdown: + if self.is_shutdown or self.allow_control_connection_query_fallback == ControlConnectionQueryFallback.SkipPoolCreation: return with host.lock: @@ -2633,20 +2684,24 @@ def __init__(self, cluster, hosts, keyspace=None): # create connection pools in parallel self._initial_connect_futures = set() - for host in hosts: - future = self.add_or_renew_pool(host, is_host_addition=False) - if future: - self._initial_connect_futures.add(future) - - futures = wait_futures(self._initial_connect_futures, return_when=FIRST_COMPLETED) - while futures.not_done and not any(f.result() for f in futures.done): - futures = wait_futures(futures.not_done, return_when=FIRST_COMPLETED) - - if not any(f.result() for f in self._initial_connect_futures): - msg = "Unable to connect to any servers" - if self.keyspace: - msg += " using keyspace '%s'" % self.keyspace - raise NoHostAvailable(msg, [h.address for h in hosts]) + fallback_mode = self.cluster.allow_control_connection_query_fallback + if fallback_mode is not ControlConnectionQueryFallback.SkipPoolCreation: + for host in hosts: + future = self.add_or_renew_pool(host, is_host_addition=False) + if future: + self._initial_connect_futures.add(future) + + futures = wait_futures(self._initial_connect_futures, return_when=FIRST_COMPLETED) + while futures.not_done and not any(f.result() for f in futures.done): + futures = wait_futures(futures.not_done, return_when=FIRST_COMPLETED) + + # Only Disabled requires an initial pool to come up. + if not any(f.result() for f in self._initial_connect_futures) and \ + fallback_mode is ControlConnectionQueryFallback.Disabled: + msg = "Unable to connect to any servers" + if self.keyspace: + msg += " using keyspace '%s'" % self.keyspace + raise NoHostAvailable(msg, [h.address for h in hosts]) self.session_id = uuid.uuid4() @@ -3245,6 +3300,9 @@ def add_or_renew_pool(self, host, is_host_addition): """ For internal use only. """ + if self.cluster.allow_control_connection_query_fallback is ControlConnectionQueryFallback.SkipPoolCreation: + return None + distance = self._profile_manager.distance(host) if distance == HostDistance.IGNORED: return None @@ -3315,6 +3373,9 @@ def update_created_pools(self): For internal use only. """ + if self.cluster.allow_control_connection_query_fallback is ControlConnectionQueryFallback.SkipPoolCreation: + return set() + futures = set() for host in self.cluster.metadata.all_hosts(): distance = self._profile_manager.distance(host) @@ -4650,6 +4711,7 @@ class ResponseFuture(object): _spec_execution_plan = NoSpeculativeExecutionPlan() _continuous_paging_session = None _host = None + _control_connection_query_attempted = False _TABLET_ROUTING_CTYPE = None _warned_timeout = False @@ -4670,6 +4732,7 @@ def __init__(self, session, message, query, timeout, metrics=None, prepared_stat self._callback_lock = Lock() self._start_time = start_time or time.time() self._host = host + self._control_connection_query_attempted = False self._spec_execution_plan = speculative_execution_plan or self._spec_execution_plan self._make_query_plan() self._event = Event() @@ -4748,11 +4811,22 @@ def _on_timeout(self, _attempts=0): self._connection.orphaned_threshold_reached = True pool.return_connection(self._connection, stream_was_orphaned=True) + elif self._connection.is_control_connection: + with self._connection.lock: + self._connection.orphaned_request_ids.add(self._req_id) + if len(self._connection.orphaned_request_ids) >= self._connection.orphaned_threshold: + self._connection.orphaned_threshold_reached = True errors = self._errors if not errors: if self.is_schema_agreed: - key = str(self._current_host.endpoint) if self._current_host else 'no host queried before timeout' + if self._current_host is None: + key = 'no host queried before timeout' + elif self._connection is not None and self._connection.is_control_connection: + control_host = self.session.cluster.get_control_connection_host() + key = str(control_host.endpoint) if control_host is not None else str(self._connection.endpoint) + else: + key = str(self._current_host.endpoint) errors = {key: "Client request timeout. See Session.execute[_async](timeout)"} else: connection = self.session.cluster.control_connection._connection @@ -4810,14 +4884,110 @@ def send_request(self, error_no_hosts=True): self._on_timeout() return True if error_no_hosts: + if self._fallback_to_control_connection(): + req_id = self._query_control_connection() + if req_id is not None: + self._req_id = req_id + return True + self._set_final_exception(NoHostAvailable( "Unable to complete the operation against any hosts", self._errors)) return False + def _has_usable_node_pool(self): + try: + pools = tuple(self.session._pools.values()) + except (AttributeError, TypeError): + return False + + return any(pool and not pool.is_shutdown for pool in pools) + + def _fallback_to_control_connection(self): + fallback_mode = self.session.cluster.allow_control_connection_query_fallback + if fallback_mode is ControlConnectionQueryFallback.Disabled: + return False + if self._host or self._control_connection_query_attempted: + return False + if fallback_mode is ControlConnectionQueryFallback.SkipPoolCreation: + return True + return not self._has_usable_node_pool() + + def _borrow_control_connection(self, connection): + with connection.lock: + if connection.in_flight >= connection.max_request_id: + raise NoConnectionsAvailable("All request IDs are currently in use") + connection.in_flight += 1 + return connection.get_request_id() + + def _release_control_connection_request(self, connection, request_id): + with connection.lock: + connection.in_flight -= 1 + connection.request_ids.append(request_id) + connection._requests.pop(request_id, None) + + def _handle_control_connection_response(self, connection, cb, response): + with connection.lock: + connection.in_flight -= 1 + cb(response) + + def _query_control_connection(self, message=None, cb=None, connection=None, host=None): + self._control_connection_query_attempted = True + + if message is None: + message = self.message + + if connection is None: + control_connection = self.session.cluster.control_connection + connection = control_connection._connection if control_connection else None + if not connection: + self._errors['control connection'] = ConnectionException("Control connection is not connected") + return None + + if host is None: + host = self.session.cluster.get_control_connection_host() or connection.endpoint + self._current_host = host + + request_id = None + request_sent = False + try: + request_id = self._borrow_control_connection(connection) + self._connection = connection + result_meta = self.prepared_statement.result_metadata if self.prepared_statement else [] + if cb is None: + cb = partial(self._set_result, host, connection, None) + cb = partial(self._handle_control_connection_response, connection, cb) + + log.debug("No usable node pools; falling back to control connection for host %s", host) + self.request_encoded_size = connection.send_msg(message, request_id, cb=cb, + encoder=self._protocol_handler.encode_message, + decoder=self._protocol_handler.decode_message, + result_metadata=result_meta) + request_sent = True + self.attempted_hosts.append(host) + return request_id + except NoConnectionsAvailable as exc: + log.debug("Control connection is at capacity") + self._errors[host] = exc + except ConnectionBusy as exc: + log.debug("Control connection is busy") + self._errors[host] = exc + except Exception as exc: + log.debug("Error querying control connection", exc_info=True) + self._errors[host] = exc + if self._metrics is not None: + self._metrics.on_connection_error() + finally: + if request_id is not None and not request_sent: + self._release_control_connection_request(connection, request_id) + + return None + def _query(self, host, message=None, cb=None): if message is None: message = self.message + self._control_connection_query_attempted = False + pool = self.session._pools.get(host) if not pool: self._errors[host] = ConnectionException("Host has been marked down or removed") @@ -4928,12 +5098,17 @@ def start_fetching_next_page(self): self._event.clear() self._final_result = _NOT_SET self._final_exception = None + self._control_connection_query_attempted = False self._start_timer() self.send_request() def _reprepare(self, prepare_message, host, connection, pool): cb = partial(self.session.submit, self._execute_after_prepare, host, connection, pool) - request_id = self._query(host, prepare_message, cb=cb) + if pool is None and connection is not None and connection.is_control_connection: + request_id = self._query_control_connection(prepare_message, cb=cb, + connection=connection, host=host) + else: + request_id = self._query(host, prepare_message, cb=cb) if request_id is None: # try to submit the original prepared statement on some other host self.send_request() @@ -4972,6 +5147,8 @@ def _set_result(self, host, connection, pool, response): if isinstance(response, ResultMessage): if response.kind == RESULT_KIND_SET_KEYSPACE: session = getattr(self, 'session', None) + if connection is not None: + connection.keyspace = response.new_keyspace # since we're running on the event loop thread, we need to # use a non-blocking method for setting the keyspace on # all connections in this session, otherwise the event @@ -5148,10 +5325,13 @@ def _execute_after_prepare(self, host, connection, pool, response): new_metadata_id = response.result_metadata_id if new_metadata_id is not None: self.prepared_statement.result_metadata_id = new_metadata_id - + # use self._query to re-use the same host and # at the same time properly borrow the connection - request_id = self._query(host) + if pool is None and connection is not None and connection.is_control_connection: + request_id = self._query_control_connection(connection=connection, host=host) + else: + request_id = self._query(host) if request_id is None: # this host errored out, move on to the next self.send_request() @@ -5264,6 +5444,11 @@ def _retry_task(self, reuse_connection, host): # to retry the operation return + if self._control_connection_query_attempted: + self._control_connection_query_attempted = False + self.send_request() + return + if reuse_connection and self._query(host) is not None: return diff --git a/docs/api/cassandra/cluster.rst b/docs/api/cassandra/cluster.rst index de8518d271..44b7b63f67 100644 --- a/docs/api/cassandra/cluster.rst +++ b/docs/api/cassandra/cluster.rst @@ -48,6 +48,8 @@ Clusters and Sessions .. autoattribute:: control_connection_timeout + .. autoattribute:: allow_control_connection_query_fallback + .. autoattribute:: idle_heartbeat_interval .. autoattribute:: idle_heartbeat_timeout @@ -106,6 +108,9 @@ Clusters and Sessions .. automethod:: set_meta_refresh_enabled +.. autoclass:: ControlConnectionQueryFallback + :members: + .. autoclass:: ExecutionProfile (load_balancing_policy=, retry_policy=None, consistency_level=ConsistencyLevel.LOCAL_ONE, serial_consistency_level=None, request_timeout=10.0, row_factory=, speculative_execution_policy=None) :members: :exclude-members: consistency_level diff --git a/tests/integration/cqlengine/model/test_model.py b/tests/integration/cqlengine/model/test_model.py index cafe6ae9c9..98d71993fd 100644 --- a/tests/integration/cqlengine/model/test_model.py +++ b/tests/integration/cqlengine/model/test_model.py @@ -259,10 +259,8 @@ class SensitiveModel(Model): rows[-1] rows[-1:] - # ignore DeprecationWarning('The loop argument is deprecated since Python 3.8, and scheduled for removal in Python 3.10.') - relevant_warnings = [warn for warn in w if "The loop argument is deprecated" not in str(warn.message)] + warning_messages = [str(warn.message) for warn in w] - assert "__table_name_case_sensitive__ will be removed in 4.0." in str(relevant_warnings[0].message) - assert "__table_name_case_sensitive__ will be removed in 4.0." in str(relevant_warnings[1].message) - assert "ModelQuerySet indexing with negative indices support will be removed in 4.0." in str(relevant_warnings[2].message) - assert "ModelQuerySet slicing with negative indices support will be removed in 4.0." in str(relevant_warnings[3].message) + assert sum("__table_name_case_sensitive__ will be removed in 4.0." in message for message in warning_messages) == 2 + assert sum("ModelQuerySet indexing with negative indices support will be removed in 4.0." in message for message in warning_messages) == 1 + assert sum("ModelQuerySet slicing with negative indices support will be removed in 4.0." in message for message in warning_messages) == 1 diff --git a/tests/integration/standard/conftest.py b/tests/integration/standard/conftest.py index 3adaf371b0..9934cfcbbb 100644 --- a/tests/integration/standard/conftest.py +++ b/tests/integration/standard/conftest.py @@ -37,6 +37,7 @@ "test_ip_change": 4, "test_authentication": 4, "test_authentication_misconfiguration": 4, + "test_control_connection_query_fallback": 4, "test_custom_cluster": 4, "test_query": 4, # Group 5: tablets (destructive — decommissions a node) diff --git a/tests/integration/standard/test_control_connection_query_fallback.py b/tests/integration/standard/test_control_connection_query_fallback.py new file mode 100644 index 0000000000..e64763a72c --- /dev/null +++ b/tests/integration/standard/test_control_connection_query_fallback.py @@ -0,0 +1,115 @@ +# Copyright DataStax, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import pytest + +from cassandra.cluster import ControlConnectionQueryFallback, NoHostAvailable + +from tests.integration import USE_CASS_EXTERNAL, TestCluster, local, remove_cluster, use_cluster + + +_CLUSTER_NAME = "control_connection_query_fallback" +_UNREACHABLE_BROADCAST_RPC_ADDRESS = "127.255.255.1" + + +def setup_module(): + if USE_CASS_EXTERNAL: + return + + remove_cluster() + + ccm_cluster = use_cluster(_CLUSTER_NAME, [1], start=False) + ccm_cluster.nodes["node1"].set_configuration_options(values={ + "broadcast_rpc_address": _UNREACHABLE_BROADCAST_RPC_ADDRESS, + }) + ccm_cluster.start(wait_for_binary_proto=True, wait_other_notice=True) + + +def teardown_module(): + if USE_CASS_EXTERNAL: + return + + remove_cluster() + + +@local +class ControlConnectionQueryFallbackIntegrationTests(unittest.TestCase): + + def setUp(self): + self.cluster = None + + def tearDown(self): + if self.cluster is not None: + self.cluster.shutdown() + + def _assert_unreachable_broadcast_rpc_metadata(self): + hosts = self.cluster.metadata.all_hosts() + assert len(hosts) == 1 + + host = hosts[0] + assert host.broadcast_rpc_address == _UNREACHABLE_BROADCAST_RPC_ADDRESS + assert host.endpoint.address == _UNREACHABLE_BROADCAST_RPC_ADDRESS + return host + + def test_disabled_raises_when_broadcast_rpc_address_is_unreachable(self): + self.cluster = TestCluster( + allow_control_connection_query_fallback=ControlConnectionQueryFallback.Disabled, + connect_timeout=1, + monitor_reporting_enabled=False, + ) + + with pytest.raises(NoHostAvailable): + self.cluster.connect() + + self._assert_unreachable_broadcast_rpc_metadata() + assert self.cluster.control_connection._connection is not None + assert self.cluster.get_all_pools() == [] + + def test_fallback_executes_queries_when_broadcast_rpc_address_is_unreachable(self): + self.cluster = TestCluster( + allow_control_connection_query_fallback=ControlConnectionQueryFallback.Fallback, + connect_timeout=1, + monitor_reporting_enabled=False, + ) + + session = self.cluster.connect() + + self._assert_unreachable_broadcast_rpc_metadata() + assert session._initial_connect_futures + assert list(session.get_pools()) == [] + + row = session.execute( + "SELECT release_version, rpc_address FROM system.local WHERE key='local'").one() + assert str(row.rpc_address) == _UNREACHABLE_BROADCAST_RPC_ADDRESS + assert row.release_version + + def test_no_node_pool_fallback_executes_queries_without_creating_pools(self): + self.cluster = TestCluster( + allow_control_connection_query_fallback=ControlConnectionQueryFallback.SkipPoolCreation, + connect_timeout=1, + monitor_reporting_enabled=False, + ) + + session = self.cluster.connect() + + self._assert_unreachable_broadcast_rpc_metadata() + assert session._initial_connect_futures == set() + assert list(session.get_pools()) == [] + + row = session.execute( + "SELECT release_version, rpc_address FROM system.local WHERE key='local'").one() + assert str(row.rpc_address) == _UNREACHABLE_BROADCAST_RPC_ADDRESS + assert row.release_version diff --git a/tests/unit/test_cluster.py b/tests/unit/test_cluster.py index b6f2da5372..3d55bc1860 100644 --- a/tests/unit/test_cluster.py +++ b/tests/unit/test_cluster.py @@ -13,6 +13,7 @@ # limitations under the License. import unittest +from concurrent.futures import Future import logging import socket from types import SimpleNamespace @@ -22,9 +23,9 @@ from cassandra import ConsistencyLevel, DriverException, Timeout, Unavailable, RequestExecutionException, ReadTimeout, WriteTimeout, CoordinationFailure, ReadFailure, WriteFailure, FunctionFailure, AlreadyExists,\ InvalidRequest, Unauthorized, AuthenticationFailed, OperationTimedOut, UnsupportedOperation, RequestValidationException, ConfigurationException, ProtocolVersion -from cassandra.cluster import _Scheduler, Session, Cluster, ResultSet, SchemaAgreementScope, default_lbp_factory, \ +from cassandra.cluster import _Scheduler, Session, Cluster, ResultSet, SchemaAgreementScope, ControlConnectionQueryFallback, default_lbp_factory, \ ExecutionProfile, _ConfigMode, EXEC_PROFILE_DEFAULT -from cassandra.connection import ConnectionBusy +from cassandra.connection import ConnectionBusy, ConnectionException from cassandra.pool import Host from cassandra.policies import HostDistance, RetryPolicy, RoundRobinPolicy, DowngradingConsistencyRetryPolicy, SimpleConvictionPolicy from cassandra.query import SimpleStatement, named_tuple_factory, tuple_factory @@ -186,6 +187,52 @@ def test_port_range(self): with pytest.raises(ValueError): cluster = Cluster(contact_points=['127.0.0.1'], port=invalid_port) + def test_control_connection_query_fallback_modes(self): + assert Cluster().allow_control_connection_query_fallback is ControlConnectionQueryFallback.Disabled + with pytest.raises(TypeError): + Cluster(allow_control_connection_query_fallback=False) + with pytest.raises(TypeError): + Cluster(allow_control_connection_query_fallback=True) + assert ( + Cluster(allow_control_connection_query_fallback=ControlConnectionQueryFallback.Fallback) + .allow_control_connection_query_fallback + is ControlConnectionQueryFallback.Fallback + ) + assert Cluster( + allow_control_connection_query_fallback=ControlConnectionQueryFallback.SkipPoolCreation + ).allow_control_connection_query_fallback is ControlConnectionQueryFallback.SkipPoolCreation + + def test_control_connection_query_fallback_no_node_pool_mode_skips_pool_creation(self): + cluster = Cluster( + allow_control_connection_query_fallback=ControlConnectionQueryFallback.SkipPoolCreation, + monitor_reporting_enabled=False, + ) + host = Host("127.0.0.1", SimpleConvictionPolicy, host_id=uuid.uuid4()) + + with patch.object(Session, "add_or_renew_pool") as mocked_add_or_renew_pool: + session = Session(cluster, [host]) + + mocked_add_or_renew_pool.assert_not_called() + assert session._initial_connect_futures == set() + assert session._pools == {} + assert session.update_created_pools() == set() + + def test_control_connection_query_fallback_fallback_tolerates_empty_initial_pools(self): + cluster = Cluster( + allow_control_connection_query_fallback=ControlConnectionQueryFallback.Fallback, + monitor_reporting_enabled=False, + ) + host = Host("127.0.0.1", SimpleConvictionPolicy, host_id=uuid.uuid4()) + future = Future() + future.set_result(False) + + with patch.object(Session, "add_or_renew_pool", return_value=future) as mocked_add_or_renew_pool: + session = Session(cluster, [host]) + + mocked_add_or_renew_pool.assert_called_once_with(host, is_host_addition=False) + assert session._initial_connect_futures == {future} + assert session._pools == {} + def test_compression_autodisabled_without_libraries(self): with patch.dict('cassandra.cluster.locally_supported_compressions', {}, clear=True): with patch('cassandra.cluster.log') as patched_logger: @@ -551,6 +598,32 @@ def test_wait_for_schema_agreement_rejects_unknown_scope(self, *_): with pytest.raises(ValueError): session.wait_for_schema_agreement(wait_time=1, scope='planet') + @mock_session_pools + def test_set_keyspace_for_all_pools_reports_all_errors(self, *_): + cluster = Cluster() + session = Session( + cluster, + [Host("127.0.0.1", SimpleConvictionPolicy, host_id=uuid.uuid4())], + ) + + pool1 = Mock(host='host1') + pool2 = Mock(host='host2') + keyspace_error = ConnectionException("boom") + + pool1._set_keyspace_for_all_conns.side_effect = ( + lambda keyspace, callback: callback(pool1, [keyspace_error]) + ) + pool2._set_keyspace_for_all_conns.side_effect = ( + lambda keyspace, callback: callback(pool2, []) + ) + session._pools = {'host1': pool1, 'host2': pool2} + + callback = Mock() + session._set_keyspace_for_all_pools('ks', callback) + + callback.assert_called_once() + assert callback.call_args.args[0] == {'host1': [keyspace_error]} + class ProtocolVersionTests(unittest.TestCase): def test_protocol_downgrade_test(self): diff --git a/tests/unit/test_response_future.py b/tests/unit/test_response_future.py index dd7fa75045..9673b0d634 100644 --- a/tests/unit/test_response_future.py +++ b/tests/unit/test_response_future.py @@ -19,7 +19,7 @@ from unittest.mock import Mock, MagicMock, ANY from cassandra import ConsistencyLevel, Unavailable, SchemaTargetType, SchemaChangeType, OperationTimedOut -from cassandra.cluster import Session, ResponseFuture, NoHostAvailable, ProtocolVersion +from cassandra.cluster import Session, ResponseFuture, NoHostAvailable, ProtocolVersion, ControlConnectionQueryFallback from cassandra.connection import Connection, ConnectionException from cassandra.protocol import (ReadTimeoutErrorMessage, WriteTimeoutErrorMessage, UnavailableErrorMessage, ResultMessage, QueryMessage, @@ -41,6 +41,7 @@ def make_basic_session(self): s = Mock(spec=Session) s.row_factory = lambda col_names, rows: [(col_names, rows)] s.cluster.control_connection._tablets_routing_v1 = False + s.cluster.allow_control_connection_query_fallback = ControlConnectionQueryFallback.Disabled return s def make_pool(self): @@ -49,6 +50,22 @@ def make_pool(self): pool.borrow_connection.return_value = [Mock(), Mock()] return pool + def make_control_connection(self): + connection = Mock(spec=Connection) + connection.endpoint = 'control-host' + connection.lock = RLock() + connection.in_flight = 0 + connection.max_request_id = 100 + connection.request_ids = deque() + connection._requests = {} + connection.orphaned_request_ids = set() + connection.orphaned_threshold = 75 + connection.orphaned_threshold_reached = False + connection.is_control_connection = True + connection.get_request_id.return_value = 7 + connection.send_msg.return_value = 128 + return connection + def make_session(self): session = self.make_basic_session() session.cluster._default_load_balancing_policy.make_query_plan.return_value = ['ip1', 'ip2'] @@ -391,6 +408,268 @@ def test_all_pools_shutdown(self): with pytest.raises(NoHostAvailable): rf.result() + def test_control_connection_fallback_disabled_by_default(self): + session = self.make_basic_session() + session.cluster._default_load_balancing_policy.make_query_plan.return_value = ['ip1'] + session._pools = {} + connection = self.make_control_connection() + session.cluster.control_connection._connection = connection + + rf = self.make_response_future(session) + rf.send_request() + + connection.send_msg.assert_not_called() + with pytest.raises(NoHostAvailable): + rf.result() + + def test_control_connection_fallback_updates_connection_keyspace(self): + session = self.make_basic_session() + session.cluster.allow_control_connection_query_fallback = ControlConnectionQueryFallback.Fallback + session.cluster._default_load_balancing_policy.make_query_plan.return_value = ['ip1'] + session._pools = {} + + def set_keyspace_for_all_pools(keyspace, callback): + session.keyspace = keyspace + callback({}) + + session._set_keyspace_for_all_pools.side_effect = set_keyspace_for_all_pools + + connection = self.make_control_connection() + connection.keyspace = 'oldks' + session.cluster.control_connection._connection = connection + control_host = Mock(endpoint=connection.endpoint) + session.cluster.get_control_connection_host.return_value = control_host + + rf = self.make_response_future(session) + assert rf.send_request() + + result = Mock(spec=ResultMessage, kind=RESULT_KIND_SET_KEYSPACE, new_keyspace='newks') + connection.send_msg.call_args[1]['cb'](result) + + assert connection.keyspace == 'newks' + assert session.keyspace == 'newks' + assert rf.result().current_rows == [] + + def test_control_connection_fallback_when_no_usable_pools(self): + session = self.make_basic_session() + session.cluster.allow_control_connection_query_fallback = ControlConnectionQueryFallback.SkipPoolCreation + session.cluster._default_load_balancing_policy.make_query_plan.return_value = ['ip1', 'ip2'] + session._pools = {} + connection = self.make_control_connection() + session.cluster.control_connection._connection = connection + control_host = Mock(endpoint=connection.endpoint) + session.cluster.get_control_connection_host.return_value = control_host + + rf = self.make_response_future(session) + assert rf.send_request() + + connection.send_msg.assert_called_once_with( + rf.message, 7, cb=ANY, encoder=ProtocolHandler.encode_message, + decoder=ProtocolHandler.decode_message, result_metadata=[]) + assert connection.in_flight == 1 + assert rf.attempted_hosts == [control_host] + + cb = connection.send_msg.call_args[1]['cb'] + expected_result = (object(), object()) + cb(self.make_mock_response(expected_result[0], expected_result[1])) + + assert connection.in_flight == 0 + assert rf.result()[0] == expected_result + + def test_control_connection_fallback_retries_after_server_error(self): + session = self.make_basic_session() + session.cluster.allow_control_connection_query_fallback = ControlConnectionQueryFallback.Fallback + session.cluster._default_load_balancing_policy.make_query_plan.return_value = ['ip1'] + session._pools = {} + connection = self.make_control_connection() + connection.get_request_id.side_effect = [7, 8] + session.cluster.control_connection._connection = connection + control_host = Mock(endpoint=connection.endpoint) + session.cluster.get_control_connection_host.return_value = control_host + + rf = self.make_response_future(session) + assert rf.send_request() + + first_response = Mock(spec=ServerError, info={}) + first_response.summary = 'boom' + first_response.to_exception.return_value = first_response + connection.send_msg.call_args[1]['cb'](first_response) + + rf.session.cluster.scheduler.schedule.assert_called_once_with(ANY, rf._retry_task, False, control_host) + + # The retry decision must come from the future state, not the live connection reference. + rf._connection = Mock(is_control_connection=False) + + rf._retry_task(False, control_host) + + assert connection.send_msg.call_count == 2 + assert connection.send_msg.call_args_list[1][0][0] is rf.message + assert connection.send_msg.call_args_list[1][0][1] == 8 + assert rf.attempted_hosts == [control_host, control_host] + + expected_result = (object(), object()) + connection.send_msg.call_args_list[1][1]['cb']( + self.make_mock_response(expected_result[0], expected_result[1])) + + assert connection.in_flight == 0 + assert rf.result()[0] == expected_result + + def test_control_connection_fallback_fetches_next_page(self): + session = self.make_basic_session() + session.cluster.allow_control_connection_query_fallback = ControlConnectionQueryFallback.Fallback + session.cluster._default_load_balancing_policy.make_query_plan.return_value = ['ip1'] + session._pools = {} + connection = self.make_control_connection() + connection.get_request_id.side_effect = [7, 8] + session.cluster.control_connection._connection = connection + control_host = Mock(endpoint=connection.endpoint) + session.cluster.get_control_connection_host.return_value = control_host + + rf = self.make_response_future(session) + assert rf.send_request() + + first_response = self.make_mock_response(['col'], [(1,)]) + first_response.paging_state = b'next-page' + connection.send_msg.call_args[1]['cb'](first_response) + + assert rf.result().current_rows == [(['col'], [(1,)])] + assert rf.has_more_pages + + rf.start_fetching_next_page() + + assert connection.send_msg.call_count == 2 + assert connection.send_msg.call_args_list[1][0][0] is rf.message + assert connection.send_msg.call_args_list[1][0][1] == 8 + assert rf.message.paging_state == b'next-page' + + second_response = self.make_mock_response(['col'], [(2,)]) + connection.send_msg.call_args_list[1][1]['cb'](second_response) + + assert connection.in_flight == 0 + assert rf.result().current_rows == [(['col'], [(2,)])] + + def test_control_connection_fallback_reprepares_prepared_statement(self): + session = self.make_basic_session() + session.cluster.allow_control_connection_query_fallback = ControlConnectionQueryFallback.Fallback + session.cluster.protocol_version = ProtocolVersion.V4 + session.cluster._default_load_balancing_policy.make_query_plan.return_value = ['ip1'] + session._pools = {} + session.submit.side_effect = lambda fn, *args, **kwargs: fn(*args, **kwargs) + + query_id = b'a' * 16 + prepared_statement = Mock( + query_id=query_id, + query_string="SELECT * FROM foobar", + keyspace="FooKeyspace", + result_metadata=[], + result_metadata_id=None) + session.cluster._prepared_statements = {query_id: prepared_statement} + + connection = self.make_control_connection() + connection.keyspace = "FooKeyspace" + connection.get_request_id.side_effect = [7, 8, 9] + session.cluster.control_connection._connection = connection + control_host = Mock(endpoint=connection.endpoint) + session.cluster.get_control_connection_host.return_value = control_host + + rf = self.make_response_future(session) + rf.prepared_statement = prepared_statement + assert rf.send_request() + + missing = Mock(spec=PreparedQueryNotFound, info=query_id) + connection.send_msg.call_args_list[0][1]['cb'](missing) + + assert connection.send_msg.call_count == 2 + prepare_message = connection.send_msg.call_args_list[1][0][0] + assert isinstance(prepare_message, PrepareMessage) + assert prepare_message.query == "SELECT * FROM foobar" + assert connection.send_msg.call_args_list[1][0][1] == 8 + + prepared_response = Mock( + spec=ResultMessage, + kind=RESULT_KIND_PREPARED, + query_id=query_id, + column_metadata=[], + result_metadata_id=None) + connection.send_msg.call_args_list[1][1]['cb'](prepared_response) + + assert connection.send_msg.call_count == 3 + assert connection.send_msg.call_args_list[2][0][0] is rf.message + assert connection.send_msg.call_args_list[2][0][1] == 9 + + expected_result = (['col'], [(1,)]) + connection.send_msg.call_args_list[2][1]['cb']( + self.make_mock_response(expected_result[0], expected_result[1])) + + assert connection.in_flight == 0 + assert rf.result()[0] == expected_result + + def test_control_connection_fallback_not_used_when_pool_can_serve(self): + session = self.make_basic_session() + session.cluster.allow_control_connection_query_fallback = ControlConnectionQueryFallback.Fallback + session.cluster._default_load_balancing_policy.make_query_plan.return_value = ['ip1'] + pool = Mock(is_shutdown=False) + pool.borrow_connection.side_effect = NoConnectionsAvailable() + session._pools = {'ip1': pool} + connection = self.make_control_connection() + session.cluster.control_connection._connection = connection + + rf = self.make_response_future(session) + rf.send_request() + + connection.send_msg.assert_not_called() + with pytest.raises(NoHostAvailable): + rf.result() + + def test_control_connection_fallback_orphans_stream_on_timeout(self): + session = self.make_basic_session() + session.cluster.allow_control_connection_query_fallback = ControlConnectionQueryFallback.Fallback + session.cluster._default_load_balancing_policy.make_query_plan.return_value = ['ip1'] + session._pools = {} + connection = self.make_control_connection() + session.cluster.control_connection._connection = connection + + def send_msg(message, request_id, cb, **kwargs): + connection._requests[request_id] = (cb, kwargs.get('decoder'), kwargs.get('result_metadata')) + return 128 + + connection.send_msg.side_effect = send_msg + + rf = self.make_response_future(session) + rf.send_request() + rf._on_timeout() + + assert 7 in connection.orphaned_request_ids + assert connection.in_flight == 1 + with pytest.raises(OperationTimedOut): + rf.result() + + def test_control_connection_fallback_timeout_without_metadata_host_uses_connection_endpoint(self): + session = self.make_basic_session() + session.cluster.allow_control_connection_query_fallback = ControlConnectionQueryFallback.Fallback + session.cluster._default_load_balancing_policy.make_query_plan.return_value = [] + session._pools = {} + session.cluster.get_control_connection_host.return_value = None + connection = self.make_control_connection() + session.cluster.control_connection._connection = connection + + def send_msg(message, request_id, cb, **kwargs): + connection._requests[request_id] = (cb, kwargs.get('decoder'), kwargs.get('result_metadata')) + return 128 + + connection.send_msg.side_effect = send_msg + + rf = self.make_response_future(session) + assert rf.send_request() + rf._on_timeout() + + with pytest.raises(OperationTimedOut) as exc_info: + rf.result() + + assert exc_info.value.errors == { + 'control-host': 'Client request timeout. See Session.execute[_async](timeout)' + } + def test_first_pool_shutdown(self): session = self.make_basic_session() session.cluster._default_load_balancing_policy.make_query_plan.return_value = ['ip1', 'ip2'] From 442f1edd7412049d438b021b1d83e7a5e2ce6f17 Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Sun, 10 May 2026 00:55:32 -0400 Subject: [PATCH 067/200] Release 3.29.10: changelog, version and documentation --- CHANGELOG.rst | 25 +++++++++++++++++++++++++ cassandra/__init__.py | 2 +- docs/conf.py | 4 ++-- docs/installation.rst | 4 ++-- 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 3ae00a7ee8..39a8aca069 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,28 @@ +3.29.10 +======= +May 10, 2026 + +Features +-------- +* Fast-path ``lookup_casstype()`` for simple type names +* Add ``Session.wait_for_schema_agreement`` + +Bug Fixes +--------- +* Fix CQL injection in ``Connection.set_keyspace_blocking`` and ``Connection.set_keyspace_async`` +* Fix libev shutdown crashes by correcting atexit registration +* Handle ``None`` ``control_connection_timeout`` in ``wait_for_schema_agreement`` +* Clean up failed heartbeat sends +* Fix ``ExponentialBackoffRetryPolicy.__init__`` super() call +* Correct ``clustering_key`` to ``clustering`` in column kind filter +* Fix inverted cooldown check in ``_get_shard_aware_endpoint`` + +Others +------ +* Deprecate ``ControlConnection.wait_for_schema_agreement`` +* Add timeout and in-flight observability to ``OperationTimedOut`` +* Drop per-query connection log + 3.29.9 ====== March 18, 2026 diff --git a/cassandra/__init__.py b/cassandra/__init__.py index 46de7daaf0..1286f20e9b 100644 --- a/cassandra/__init__.py +++ b/cassandra/__init__.py @@ -23,7 +23,7 @@ def emit(self, record): logging.getLogger('cassandra').addHandler(NullHandler()) -__version_info__ = (3, 29, 9) +__version_info__ = (3, 29, 10) __version__ = '.'.join(map(str, __version_info__)) diff --git a/docs/conf.py b/docs/conf.py index 87a38c6add..34ef31ccae 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -29,11 +29,11 @@ '3.29.6-scylla', '3.29.7-scylla', '3.29.8-scylla', - '3.29.9-scylla', + '3.29.10-scylla', ] BRANCHES = ['master'] # Set the latest version. -LATEST_VERSION = '3.29.9-scylla' +LATEST_VERSION = '3.29.10-scylla' # Set which versions are not released yet. UNSTABLE_VERSIONS = ['master'] # Set which versions are deprecated diff --git a/docs/installation.rst b/docs/installation.rst index fbb9ac4043..6a4b38ea80 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -26,7 +26,7 @@ To check if the installation was successful, you can run:: python -c 'import cassandra; print(cassandra.__version__)' -It should print something like "3.29.9". +It should print something like "3.29.10". (*Optional*) Compression Support -------------------------------- @@ -190,7 +190,7 @@ through `Homebrew `_. For example, on Mac OS X:: $ brew install libev -The libev extension can now be built for Windows as of Python driver version 3.29.9. You can +The libev extension can now be built for Windows as of Python driver version 3.29.10. You can install libev using any Windows package manager. For example, to install using `vcpkg `_: $ vcpkg install libev From 69bb8efc9a3742de4de3b4fac61086b5b310db08 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Wed, 22 Apr 2026 10:43:51 +0200 Subject: [PATCH 068/200] tests: replace SimpleStrategy with NetworkTopologyStrategy Replace SimpleStrategy with NetworkTopologyStrategy across integration tests to align with ScyllaDB's tablet-based replication defaults. In the tablets test module, skip default keyspace creation (set_keyspace=False) to avoid RF=3 keyspaces that block node decommission when all nodes already hold replicas. --- tests/integration/__init__.py | 8 ++--- .../column_encryption/test_policies.py | 2 +- .../standard/test_client_routes.py | 2 +- tests/integration/standard/test_cluster.py | 4 +-- ..._concurrent_schema_change_and_node_kill.py | 2 +- .../standard/test_control_connection.py | 2 +- .../standard/test_custom_protocol_handler.py | 11 ++++--- .../standard/test_cython_protocol_handlers.py | 4 +-- tests/integration/standard/test_metadata.py | 32 ++++++++++++------- tests/integration/standard/test_policies.py | 2 +- .../standard/test_prepared_statements.py | 4 +-- tests/integration/standard/test_query.py | 4 +-- .../standard/test_rate_limit_exceeded.py | 2 +- .../integration/standard/test_shard_aware.py | 4 ++- tests/integration/standard/test_tablets.py | 2 +- tests/integration/standard/test_udts.py | 10 +++--- .../integration/standard/test_use_keyspace.py | 2 +- 17 files changed, 54 insertions(+), 43 deletions(-) diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py index 6a809bded4..7d4d47c9a7 100644 --- a/tests/integration/__init__.py +++ b/tests/integration/__init__.py @@ -651,17 +651,17 @@ def setup_keyspace(ipformat=None, protocol_version=None, port=9042): ddl = ''' CREATE KEYSPACE test3rf - WITH replication = {'class': 'SimpleStrategy', 'replication_factor': '3'}''' + WITH replication = {'class': 'NetworkTopologyStrategy', 'replication_factor': '3'}''' execute_with_long_wait_retry(session, ddl) ddl = ''' CREATE KEYSPACE test2rf - WITH replication = {'class': 'SimpleStrategy', 'replication_factor': '2'}''' + WITH replication = {'class': 'NetworkTopologyStrategy', 'replication_factor': '2'}''' execute_with_long_wait_retry(session, ddl) ddl = ''' CREATE KEYSPACE test1rf - WITH replication = {'class': 'SimpleStrategy', 'replication_factor': '1'}''' + WITH replication = {'class': 'NetworkTopologyStrategy', 'replication_factor': '1'}''' execute_with_long_wait_retry(session, ddl) ddl_3f = ''' @@ -774,7 +774,7 @@ def drop_keyspace(cls): @classmethod def create_keyspace(cls, rf): - ddl = "CREATE KEYSPACE {0} WITH replication = {{'class': 'SimpleStrategy', 'replication_factor': '{1}'}}".format(cls.ks_name, rf) + ddl = "CREATE KEYSPACE {0} WITH replication = {{'class': 'NetworkTopologyStrategy', 'replication_factor': '{1}'}}".format(cls.ks_name, rf) execute_with_long_wait_retry(cls.session, ddl) @classmethod diff --git a/tests/integration/standard/column_encryption/test_policies.py b/tests/integration/standard/column_encryption/test_policies.py index 9a1d186895..4b12fa135a 100644 --- a/tests/integration/standard/column_encryption/test_policies.py +++ b/tests/integration/standard/column_encryption/test_policies.py @@ -30,7 +30,7 @@ class ColumnEncryptionPolicyTest(unittest.TestCase): def _recreate_keyspace(self, session): session.execute("drop keyspace if exists foo") - session.execute("CREATE KEYSPACE foo WITH replication = {'class': 'SimpleStrategy', 'replication_factor': '1'}") + session.execute("CREATE KEYSPACE foo WITH replication = {'class': 'NetworkTopologyStrategy', 'replication_factor': '1'}") session.execute("CREATE TABLE foo.bar(encrypted blob, unencrypted int, primary key(unencrypted))") def _create_policy(self, key, iv = None): diff --git a/tests/integration/standard/test_client_routes.py b/tests/integration/standard/test_client_routes.py index 5a20421276..290d1741f7 100644 --- a/tests/integration/standard/test_client_routes.py +++ b/tests/integration/standard/test_client_routes.py @@ -741,7 +741,7 @@ def test_queries_succeed_through_proxy(self): session = cluster.connect() session.execute( "CREATE KEYSPACE IF NOT EXISTS test_cr_ks " - "WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 3}" + "WITH replication = {'class':'NetworkTopologyStrategy', 'replication_factor': 3}" ) session.execute( "CREATE TABLE IF NOT EXISTS test_cr_ks.t (k int PRIMARY KEY, v text)" diff --git a/tests/integration/standard/test_cluster.py b/tests/integration/standard/test_cluster.py index 08b823d716..15e525f43c 100644 --- a/tests/integration/standard/test_cluster.py +++ b/tests/integration/standard/test_cluster.py @@ -180,7 +180,7 @@ def test_basic(self): result = execute_until_pass(session, """ CREATE KEYSPACE clustertests - WITH replication = {'class': 'SimpleStrategy', 'replication_factor': '1'} + WITH replication = {'class': 'NetworkTopologyStrategy', 'replication_factor': '1'} """) assert not result @@ -1506,7 +1506,7 @@ def test_prepare_on_ignored_hosts(self): hosts = cluster.metadata.all_hosts() session.execute("CREATE KEYSPACE clustertests " "WITH replication = " - "{'class': 'SimpleStrategy', 'replication_factor': '1'}") + "{'class': 'NetworkTopologyStrategy', 'replication_factor': '1'}") session.execute("CREATE TABLE clustertests.tab (a text, PRIMARY KEY (a))") # assign to an unused variable so cluster._prepared_statements retains # reference diff --git a/tests/integration/standard/test_concurrent_schema_change_and_node_kill.py b/tests/integration/standard/test_concurrent_schema_change_and_node_kill.py index 910dcaa9fe..9a9a3d325f 100644 --- a/tests/integration/standard/test_concurrent_schema_change_and_node_kill.py +++ b/tests/integration/standard/test_concurrent_schema_change_and_node_kill.py @@ -27,7 +27,7 @@ def test_schema_change_after_node_kill(self): "DROP KEYSPACE IF EXISTS ks_deadlock;") self.session.execute( "CREATE KEYSPACE IF NOT EXISTS ks_deadlock " - "WITH replication = {'class': 'SimpleStrategy', 'replication_factor': '2' };") + "WITH replication = {'class': 'NetworkTopologyStrategy', 'replication_factor': '2' };") self.session.set_keyspace('ks_deadlock') self.session.execute("CREATE TABLE IF NOT EXISTS some_table(k int, c int, v int, PRIMARY KEY (k, v));") self.session.execute("INSERT INTO some_table (k, c, v) VALUES (1, 2, 3);") diff --git a/tests/integration/standard/test_control_connection.py b/tests/integration/standard/test_control_connection.py index c4463e17fd..f0c41dde14 100644 --- a/tests/integration/standard/test_control_connection.py +++ b/tests/integration/standard/test_control_connection.py @@ -68,7 +68,7 @@ def test_drop_keyspace(self): self.session = self.cluster.connect() self.session.execute(""" CREATE KEYSPACE keyspacetodrop - WITH replication = { 'class' : 'SimpleStrategy', 'replication_factor': '1' } + WITH replication = { 'class' : 'NetworkTopologyStrategy', 'replication_factor': '1' } """) self.session.set_keyspace("keyspacetodrop") self.session.execute("CREATE TYPE user (age int, name text)") diff --git a/tests/integration/standard/test_custom_protocol_handler.py b/tests/integration/standard/test_custom_protocol_handler.py index e123f2050e..e7d336014f 100644 --- a/tests/integration/standard/test_custom_protocol_handler.py +++ b/tests/integration/standard/test_custom_protocol_handler.py @@ -42,8 +42,9 @@ class CustomProtocolHandlerTest(unittest.TestCase): def setUpClass(cls): cls.cluster = TestCluster() cls.session = cls.cluster.connect() - cls.session.execute("CREATE KEYSPACE custserdes WITH replication = { 'class' : 'SimpleStrategy', 'replication_factor': '1'}") + cls.session.execute("CREATE KEYSPACE custserdes WITH replication = { 'class' : 'NetworkTopologyStrategy', 'replication_factor': '1'}") cls.session.set_keyspace("custserdes") + cls.session.execute("CREATE TABLE IF NOT EXISTS custserdes.test (k int PRIMARY KEY, v int)") @classmethod def tearDownClass(cls): @@ -165,7 +166,7 @@ def test_protocol_divergence_v5_fail_by_flag_uses_int(self): int_flag=False) def _send_query_message(self, session, timeout, **kwargs): - query = "SELECT * FROM test3rf.test" + query = "SELECT * FROM custserdes.test" message = QueryMessage(query=query, **kwargs) future = ResponseFuture(session, message, query=None, timeout=timeout) future.send_request() @@ -175,8 +176,8 @@ def _protocol_divergence_fail_by_flag_uses_int(self, version, uses_int_query_fla cluster = TestCluster(protocol_version=version, allow_beta_protocol_version=beta) session = cluster.connect() - query_one = SimpleStatement("INSERT INTO test3rf.test (k, v) VALUES (1, 1)") - query_two = SimpleStatement("INSERT INTO test3rf.test (k, v) VALUES (2, 2)") + query_one = SimpleStatement("INSERT INTO custserdes.test (k, v) VALUES (1, 1)") + query_two = SimpleStatement("INSERT INTO custserdes.test (k, v) VALUES (2, 2)") execute_with_long_wait_retry(session, query_one) execute_with_long_wait_retry(session, query_two) @@ -190,7 +191,7 @@ def _protocol_divergence_fail_by_flag_uses_int(self, version, uses_int_query_fla # This means the flag are not handled as they are meant by the server if uses_int=False assert response.has_more_pages == uses_int_query_flag - execute_with_long_wait_retry(session, SimpleStatement("TRUNCATE test3rf.test")) + execute_with_long_wait_retry(session, SimpleStatement("TRUNCATE custserdes.test")) cluster.shutdown() diff --git a/tests/integration/standard/test_cython_protocol_handlers.py b/tests/integration/standard/test_cython_protocol_handlers.py index 9c94b2ac77..49a13ac23a 100644 --- a/tests/integration/standard/test_cython_protocol_handlers.py +++ b/tests/integration/standard/test_cython_protocol_handlers.py @@ -34,7 +34,7 @@ def setUpClass(cls): cls.cluster = TestCluster() cls.session = cls.cluster.connect() cls.session.execute("CREATE KEYSPACE testspace WITH replication = " - "{ 'class' : 'SimpleStrategy', 'replication_factor': '1'}") + "{ 'class' : 'NetworkTopologyStrategy', 'replication_factor': '1'}") cls.session.set_keyspace("testspace") cls.colnames = create_table_with_all_types("test_table", cls.session, cls.N_ITEMS) @@ -225,7 +225,7 @@ def setUpClass(cls): cls.cluster = TestCluster() cls.session = cls.cluster.connect() cls.session.execute("CREATE KEYSPACE IF NOT EXISTS test_wide_table WITH replication = " - "{ 'class' : 'SimpleStrategy', 'replication_factor': '1'}") + "{ 'class' : 'NetworkTopologyStrategy', 'replication_factor': '1'}") cls.session.set_keyspace("test_wide_table") # Create a wide table with many int columns diff --git a/tests/integration/standard/test_metadata.py b/tests/integration/standard/test_metadata.py index 6e64401a75..d34b81d44d 100644 --- a/tests/integration/standard/test_metadata.py +++ b/tests/integration/standard/test_metadata.py @@ -230,8 +230,8 @@ def test_basic_table_meta_properties(self): assert ksmeta.name == self.keyspace_name assert ksmeta.durable_writes - assert ksmeta.replication_strategy.name == 'SimpleStrategy' - assert ksmeta.replication_strategy.replication_factor == 1 + assert ksmeta.replication_strategy.name == 'NetworkTopologyStrategy' + assert ksmeta.replication_strategy.dc_replication_factors["dc1"] == 1 assert self.function_table_name in ksmeta.tables tablemeta = ksmeta.tables[self.function_table_name] @@ -448,6 +448,8 @@ def test_dense_compact_storage(self): tablemeta = self.get_table_metadata() self.check_create_statement(tablemeta, create_statement) + @xfail_scylla_version_lt(reason='scylladb/scylladb#22677 - Counters are not yet supported with tablets', + oss_scylla_version="7.0", ent_scylla_version="2026.1") def test_counter(self): create_statement = ( "CREATE TABLE {keyspace}.{table} (" @@ -601,7 +603,7 @@ def test_refresh_schema_metadata(self): assert "new_keyspace" not in cluster2.metadata.keyspaces # Cluster metadata modification - self.session.execute("CREATE KEYSPACE new_keyspace WITH replication = {'class': 'SimpleStrategy', 'replication_factor': '1'}") + self.session.execute("CREATE KEYSPACE new_keyspace WITH replication = {'class': 'NetworkTopologyStrategy', 'replication_factor': '1'}") assert "new_keyspace" not in cluster2.metadata.keyspaces cluster2.refresh_schema_metadata() @@ -722,6 +724,8 @@ def test_refresh_table_metadata(self): cluster2.shutdown() @greaterthanorequalcass30 + @xfail_scylla_version_lt(reason='scylladb/scylladb#22677 - Secondary indexes are not supported on base tables with tablets', + oss_scylla_version="7.0", ent_scylla_version="2026.1") def test_refresh_metadata_for_mv(self): """ test for synchronously refreshing materialized view metadata @@ -931,6 +935,8 @@ def test_refresh_user_aggregate_metadata(self): @greaterthanorequalcass30 @requires_collection_indexes + @xfail_scylla_version_lt(reason='scylladb/scylladb#22677 - Secondary indexes are not supported on base tables with tablets', + oss_scylla_version="7.0", ent_scylla_version="2026.1") def test_multiple_indices(self): """ test multiple indices on the same column. @@ -964,6 +970,8 @@ def test_multiple_indices(self): assert index_2.keyspace_name == "schemametadatatests" @greaterthanorequalcass30 + @xfail_scylla_version_lt(reason='scylladb/scylladb#22677 - Secondary indexes are not supported on base tables with tablets', + oss_scylla_version="7.0", ent_scylla_version="2026.1") def test_table_extensions(self): s = self.session ks = self.keyspace_name @@ -1077,7 +1085,7 @@ def test_metadata_pagination_keyspaces(self): for ks in keyspaces: self.session.execute( - f"CREATE KEYSPACE IF NOT EXISTS {ks} WITH REPLICATION = {{ 'class' : 'SimpleStrategy', 'replication_factor' : 3 }}" + f"CREATE KEYSPACE IF NOT EXISTS {ks} WITH REPLICATION = {{ 'class' : 'NetworkTopologyStrategy', 'replication_factor' : 3 }}" ) self.cluster.schema_metadata_page_size = 2000 @@ -1138,7 +1146,7 @@ def test_export_keyspace_schema_udts(self): session.execute(""" CREATE KEYSPACE export_udts - WITH replication = {'class': 'SimpleStrategy', 'replication_factor': '1'} + WITH replication = {'class': 'NetworkTopologyStrategy', 'replication_factor': '1'} AND durable_writes = true; """) session.execute(""" @@ -1162,7 +1170,7 @@ def test_export_keyspace_schema_udts(self): addresses map>) """) - expected_prefix = """CREATE KEYSPACE export_udts WITH replication = {'class': 'SimpleStrategy', 'replication_factor': '1'} AND durable_writes = true; + expected_prefix = """CREATE KEYSPACE export_udts WITH replication = {'class': 'NetworkTopologyStrategy', 'replication_factor': '1'} AND durable_writes = true; CREATE TYPE export_udts.street ( street_number int, @@ -1212,7 +1220,7 @@ def test_case_sensitivity(self): session.execute("DROP KEYSPACE IF EXISTS {0}".format(ksname)) session.execute(""" CREATE KEYSPACE "%s" - WITH replication = {'class': 'SimpleStrategy', 'replication_factor': '1'} + WITH replication = {'class': 'NetworkTopologyStrategy', 'replication_factor': '1'} """ % (ksname,)) session.execute(""" CREATE TABLE "%s"."%s" ( @@ -1256,7 +1264,7 @@ def test_already_exists_exceptions(self): ddl = ''' CREATE KEYSPACE %s - WITH replication = {'class': 'SimpleStrategy', 'replication_factor': '3'}''' + WITH replication = {'class': 'NetworkTopologyStrategy', 'replication_factor': '3'}''' with pytest.raises(AlreadyExists): session.execute(ddl % ksname) @@ -1387,7 +1395,7 @@ def setUp(self): self.session = self.cluster.connect() name = self._testMethodName.lower() crt_ks = ''' - CREATE KEYSPACE %s WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1} AND durable_writes = true''' % name + CREATE KEYSPACE %s WITH replication = {'class': 'NetworkTopologyStrategy', 'replication_factor': 1} AND durable_writes = true''' % name self.session.execute(crt_ks) def tearDown(self): @@ -1437,7 +1445,7 @@ def setup_class(cls): cls.session.execute( """ CREATE KEYSPACE %s - WITH replication = {'class': 'SimpleStrategy', 'replication_factor': '1'}; + WITH replication = {'class': 'NetworkTopologyStrategy', 'replication_factor': '1'}; """ % cls.keyspace_name) cls.session.set_keyspace(cls.keyspace_name) except Exception: @@ -1540,7 +1548,7 @@ def setup_class(cls): cls.cluster = TestCluster() cls.keyspace_name = cls.__name__.lower() cls.session = cls.cluster.connect() - cls.session.execute("CREATE KEYSPACE IF NOT EXISTS %s WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1}" % cls.keyspace_name) + cls.session.execute("CREATE KEYSPACE IF NOT EXISTS %s WITH replication = {'class': 'NetworkTopologyStrategy', 'replication_factor': 1}" % cls.keyspace_name) cls.session.set_keyspace(cls.keyspace_name) cls.keyspace_function_meta = cls.cluster.metadata.keyspaces[cls.keyspace_name].functions cls.keyspace_aggregate_meta = cls.cluster.metadata.keyspaces[cls.keyspace_name].aggregates @@ -2007,7 +2015,7 @@ def setup_class(cls): cls.cluster = TestCluster() cls.keyspace_name = cls.__name__.lower() cls.session = cls.cluster.connect() - cls.session.execute("CREATE KEYSPACE %s WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1}" % cls.keyspace_name) + cls.session.execute("CREATE KEYSPACE %s WITH replication = {'class': 'NetworkTopologyStrategy', 'replication_factor': '1'}" % cls.keyspace_name) cls.session.set_keyspace(cls.keyspace_name) connection = cls.cluster.control_connection._connection diff --git a/tests/integration/standard/test_policies.py b/tests/integration/standard/test_policies.py index 2de12f7b7f..50b431e3c9 100644 --- a/tests/integration/standard/test_policies.py +++ b/tests/integration/standard/test_policies.py @@ -104,5 +104,5 @@ def test_exponential_retries(self): self.session.execute( """ CREATE KEYSPACE preparedtests - WITH replication = {'class': 'SimpleStrategy', 'replication_factor': '1'} + WITH replication = {'class': 'NetworkTopologyStrategy', 'replication_factor': '1'} """) diff --git a/tests/integration/standard/test_prepared_statements.py b/tests/integration/standard/test_prepared_statements.py index 3f63b881ef..37f93c94c6 100644 --- a/tests/integration/standard/test_prepared_statements.py +++ b/tests/integration/standard/test_prepared_statements.py @@ -62,7 +62,7 @@ def test_basic(self): self.session.execute( """ CREATE KEYSPACE preparedtests - WITH replication = {'class': 'SimpleStrategy', 'replication_factor': '1'} + WITH replication = {'class': 'NetworkTopologyStrategy', 'replication_factor': '1'} """) self.session.set_keyspace("preparedtests") @@ -437,7 +437,7 @@ def test_fail_if_different_query_id_on_reprepare(self): keyspace = "test_fail_if_different_query_id_on_reprepare" self.session.execute( "CREATE KEYSPACE IF NOT EXISTS {} WITH replication = " - "{{'class': 'SimpleStrategy', 'replication_factor': 1}}".format(keyspace) + "{{'class': 'NetworkTopologyStrategy', 'replication_factor': 1}}".format(keyspace) ) self.session.execute("CREATE TABLE IF NOT EXISTS {}.foo(k int PRIMARY KEY)".format(keyspace)) prepared = self.session.prepare("SELECT * FROM {}.foo WHERE k=?".format(keyspace)) diff --git a/tests/integration/standard/test_query.py b/tests/integration/standard/test_query.py index f9d3dc26bc..91ad4fa559 100644 --- a/tests/integration/standard/test_query.py +++ b/tests/integration/standard/test_query.py @@ -1359,12 +1359,12 @@ def setUpClass(cls): cls.table_name = "table_query_keyspace_tests" ddl = """CREATE KEYSPACE {0} WITH replication = - {{'class': 'SimpleStrategy', + {{'class': 'NetworkTopologyStrategy', 'replication_factor': '{1}'}}""".format(cls.ks_name, 1) cls.session.execute(ddl) ddl = """CREATE KEYSPACE {0} WITH replication = - {{'class': 'SimpleStrategy', + {{'class': 'NetworkTopologyStrategy', 'replication_factor': '{1}'}}""".format(cls.alternative_ks, 1) cls.session.execute(ddl) diff --git a/tests/integration/standard/test_rate_limit_exceeded.py b/tests/integration/standard/test_rate_limit_exceeded.py index ea7dfc7d61..5a7fc5dc74 100644 --- a/tests/integration/standard/test_rate_limit_exceeded.py +++ b/tests/integration/standard/test_rate_limit_exceeded.py @@ -33,7 +33,7 @@ def test_rate_limit_exceeded(self): self.session.execute( """ CREATE KEYSPACE IF NOT EXISTS ratetests - WITH REPLICATION = {'class' : 'SimpleStrategy', 'replication_factor' : 1} + WITH REPLICATION = {'class' : 'NetworkTopologyStrategy', 'replication_factor' : 1} """) self.session.execute("USE ratetests") diff --git a/tests/integration/standard/test_shard_aware.py b/tests/integration/standard/test_shard_aware.py index d1f3e27abd..4a6c7887d8 100644 --- a/tests/integration/standard/test_shard_aware.py +++ b/tests/integration/standard/test_shard_aware.py @@ -89,7 +89,7 @@ def create_ks_and_cf(self): self.session.execute( """ CREATE KEYSPACE preparedtests - WITH replication = {'class': 'SimpleStrategy', 'replication_factor': '3'} + WITH replication = {'class': 'NetworkTopologyStrategy', 'replication_factor': '3'} AND tablets = {'enabled': false} """) self.session.execute("USE preparedtests") @@ -174,6 +174,8 @@ def test_all_tracing_coming_one_shard(self): using the traces to validate that all the action been executed on the the same shard. this test is using prepared SELECT statements for this validation + + Requires tablets to be disabled to ensure shard consistency. """ self.create_ks_and_cf() diff --git a/tests/integration/standard/test_tablets.py b/tests/integration/standard/test_tablets.py index d969140339..45e8a807ea 100644 --- a/tests/integration/standard/test_tablets.py +++ b/tests/integration/standard/test_tablets.py @@ -9,7 +9,7 @@ def setup_module(): - use_cluster('tablets', [3], start=True) + use_cluster('tablets', [3], start=True, set_keyspace=False) class TestTabletsIntegration: diff --git a/tests/integration/standard/test_udts.py b/tests/integration/standard/test_udts.py index 18f3dfb298..11888adda4 100644 --- a/tests/integration/standard/test_udts.py +++ b/tests/integration/standard/test_udts.py @@ -94,7 +94,7 @@ def test_can_insert_unprepared_registered_udts(self): # use the same UDT name in a different keyspace s.execute(""" CREATE KEYSPACE udt_test_unprepared_registered2 - WITH replication = { 'class' : 'SimpleStrategy', 'replication_factor': '1' } + WITH replication = { 'class' : 'NetworkTopologyStrategy', 'replication_factor': '1' } """) s.set_keyspace("udt_test_unprepared_registered2") s.execute("CREATE TYPE user (state text, is_cool boolean)") @@ -124,14 +124,14 @@ def test_can_register_udt_before_connecting(self): s.execute(""" CREATE KEYSPACE udt_test_register_before_connecting - WITH replication = { 'class' : 'SimpleStrategy', 'replication_factor': '1' } + WITH replication = { 'class' : 'NetworkTopologyStrategy', 'replication_factor': '1' } """) s.execute("CREATE TYPE udt_test_register_before_connecting.user (age int, name text)") s.execute("CREATE TABLE udt_test_register_before_connecting.mytable (a int PRIMARY KEY, b frozen)") s.execute(""" CREATE KEYSPACE udt_test_register_before_connecting2 - WITH replication = { 'class' : 'SimpleStrategy', 'replication_factor': '1' } + WITH replication = { 'class' : 'NetworkTopologyStrategy', 'replication_factor': '1' } """) s.execute("CREATE TYPE udt_test_register_before_connecting2.user (state text, is_cool boolean)") s.execute("CREATE TABLE udt_test_register_before_connecting2.mytable (a int PRIMARY KEY, b frozen)") @@ -193,7 +193,7 @@ def test_can_insert_prepared_unregistered_udts(self): # use the same UDT name in a different keyspace s.execute(""" CREATE KEYSPACE udt_test_prepared_unregistered2 - WITH replication = { 'class' : 'SimpleStrategy', 'replication_factor': '1' } + WITH replication = { 'class' : 'NetworkTopologyStrategy', 'replication_factor': '1' } """) s.set_keyspace("udt_test_prepared_unregistered2") s.execute("CREATE TYPE user (state text, is_cool boolean)") @@ -240,7 +240,7 @@ def test_can_insert_prepared_registered_udts(self): # use the same UDT name in a different keyspace s.execute(""" CREATE KEYSPACE udt_test_prepared_registered2 - WITH replication = { 'class' : 'SimpleStrategy', 'replication_factor': '1' } + WITH replication = { 'class' : 'NetworkTopologyStrategy', 'replication_factor': '1' } """) s.set_keyspace("udt_test_prepared_registered2") s.execute("CREATE TYPE user (state text, is_cool boolean)") diff --git a/tests/integration/standard/test_use_keyspace.py b/tests/integration/standard/test_use_keyspace.py index 80e7cfe5f3..9eb3f5be36 100644 --- a/tests/integration/standard/test_use_keyspace.py +++ b/tests/integration/standard/test_use_keyspace.py @@ -65,7 +65,7 @@ def patched_set_keyspace_blocking(*args, **kwargs): return original_set_keyspace_blocking(*args, **kwargs) with patch.object(Connection, "set_keyspace_blocking", patched_set_keyspace_blocking): - self.session.execute("CREATE KEYSPACE test_set_keyspace WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1}") + self.session.execute("CREATE KEYSPACE test_set_keyspace WITH replication = {'class': 'NetworkTopologyStrategy', 'replication_factor': 1}") self.session.execute("CREATE TABLE test_set_keyspace.set_keyspace_slow_connection(pk int, PRIMARY KEY(pk))") session2 = self.cluster.connect() From 445b5afb4a08690f933beb6e4b70cdd1b1e8d8fb Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Wed, 22 Apr 2026 14:10:57 +0200 Subject: [PATCH 069/200] tests: bootstrap 3 new nodes in full node replacement test With tablets enabled, decommissioning a node from a 3-node cluster with RF=3 fails because there is no available node to receive tablet replicas. Bootstrap 3 replacement nodes instead of 2 so that each original node can be decommissioned while sufficient replicas remain. --- tests/integration/standard/test_client_routes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/standard/test_client_routes.py b/tests/integration/standard/test_client_routes.py index 290d1741f7..292eabca30 100644 --- a/tests/integration/standard/test_client_routes.py +++ b/tests/integration/standard/test_client_routes.py @@ -1154,7 +1154,7 @@ def tearDownClass(cls): def test_should_survive_full_node_replacement_through_nlb(self): """ 1. Start with 3 nodes behind the NLB - 2. Bootstrap 2 new nodes, add to NLB, update routes + 2. Bootstrap 3 new nodes, add to NLB, update routes 3. Decommission the original 3 nodes one-by-one, updating NLB/routes 4. Verify the session survives with only new nodes """ @@ -1190,7 +1190,7 @@ def test_should_survive_full_node_replacement_through_nlb(self): len(original_node_ids)) # ---- Stage 3: Bootstrap new nodes ---- - new_node_ids = [max(original_node_ids) + 1, max(original_node_ids) + 2] + new_node_ids = [max(original_node_ids) + 1, max(original_node_ids) + 2, max(original_node_ids) + 3] log.info("Stage 3: Adding nodes %s", new_node_ids) ccm_cluster = get_cluster() From 2b5dd164a3c047fe4826b2df1815562842fd1e4c Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Thu, 23 Apr 2026 08:16:51 +0200 Subject: [PATCH 070/200] tests: xfail LWT tests on Scylla versions without tablet LWT support LWT is not supported with tablets on ScyllaDB < 2025.4. Mark the affected SerialConsistencyTests and LightweightTransactionTests as xfail for those versions. --- tests/integration/standard/test_query.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/integration/standard/test_query.py b/tests/integration/standard/test_query.py index 91ad4fa559..4f460459c0 100644 --- a/tests/integration/standard/test_query.py +++ b/tests/integration/standard/test_query.py @@ -26,7 +26,7 @@ from cassandra.policies import HostDistance, RoundRobinPolicy, WhiteListRoundRobinPolicy from tests.integration import use_singledc, PROTOCOL_VERSION, BasicSharedKeyspaceUnitTestCase, \ greaterthanprotocolv3, MockLoggingHandler, get_supported_protocol_versions, local, get_cluster, setup_keyspace, \ - USE_CASS_EXTERNAL, greaterthanorequalcass40, TestCluster, xfail_scylla + USE_CASS_EXTERNAL, greaterthanorequalcass40, TestCluster, xfail_scylla, xfail_scylla_version_lt from tests import notwindows from tests.integration import greaterthanorequalcass30, get_node from tests.util import assertListEqual, wait_until @@ -804,6 +804,9 @@ def setUp(self): def tearDown(self): self.cluster.shutdown() + @xfail_scylla_version_lt(reason='scylladb/scylladb#18068 - LWT is not yet supported with tablets', + scylla_version='2025.4', + raises=InvalidRequest) def test_conditional_update(self): self.session.execute("INSERT INTO test3rf.test (k, v) VALUES (0, 0)") statement = SimpleStatement( @@ -828,6 +831,9 @@ def test_conditional_update(self): assert result assert result.one().applied + @xfail_scylla_version_lt(reason='scylladb/scylladb#18068 - LWT is not yet supported with tablets', + scylla_version='2025.4', + raises=InvalidRequest) def test_conditional_update_with_prepared_statements(self): self.session.execute("INSERT INTO test3rf.test (k, v) VALUES (0, 0)") statement = self.session.prepare( @@ -850,6 +856,9 @@ def test_conditional_update_with_prepared_statements(self): assert result assert result.one().applied + @xfail_scylla_version_lt(reason='scylladb/scylladb#18068 - LWT is not yet supported with tablets', + scylla_version='2025.4', + raises=InvalidRequest) def test_conditional_update_with_batch_statements(self): self.session.execute("INSERT INTO test3rf.test (k, v) VALUES (0, 0)") statement = BatchStatement(serial_consistency_level=ConsistencyLevel.SERIAL) @@ -915,6 +924,9 @@ def tearDown(self): self.session.execute("DROP TABLE test3rf.lwt_clustering") self.cluster.shutdown() + @xfail_scylla_version_lt(reason='scylladb/scylladb#18068 - LWT is not yet supported with tablets', + scylla_version='2025.4', + raises=AttributeError) def test_no_connection_refused_on_timeout(self): """ Test for PYTHON-91 "Connection closed after LWT timeout" From e7cb651ad863f60c48eca1b924b1236445507eb2 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Mon, 4 May 2026 16:04:03 +0200 Subject: [PATCH 071/200] tests: xfail tests on Scylla version without indexes tablet support Secondary indexes are not supported on base tables with tablets for Scylla versions < 2026.1. --- .../integration/cqlengine/query/test_named.py | 4 +++- tests/integration/standard/test_metadata.py | 22 ++++++++++++++----- tests/integration/standard/test_query.py | 2 ++ 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/tests/integration/cqlengine/query/test_named.py b/tests/integration/cqlengine/query/test_named.py index 24a6802b47..4923a8a583 100644 --- a/tests/integration/cqlengine/query/test_named.py +++ b/tests/integration/cqlengine/query/test_named.py @@ -27,7 +27,7 @@ from tests.integration.cqlengine.query.test_queryset import BaseQuerySetUsage -from tests.integration import BasicSharedKeyspaceUnitTestCase, greaterthanorequalcass30, requires_collection_indexes +from tests.integration import BasicSharedKeyspaceUnitTestCase, greaterthanorequalcass30, requires_collection_indexes, xfail_scylla_version_lt import pytest @@ -292,6 +292,8 @@ def tearDownClass(cls): super(TestNamedWithMV, cls).tearDownClass() @greaterthanorequalcass30 + @xfail_scylla_version_lt(reason='scylladb/scylladb#22677 - Materialized views and secondary indexes are not supported on base tables with tablets.', + scylla_version='2026.1') @execute_count(5) def test_named_table_with_mv(self): """ diff --git a/tests/integration/standard/test_metadata.py b/tests/integration/standard/test_metadata.py index d34b81d44d..84ec6c9ea5 100644 --- a/tests/integration/standard/test_metadata.py +++ b/tests/integration/standard/test_metadata.py @@ -449,7 +449,7 @@ def test_dense_compact_storage(self): self.check_create_statement(tablemeta, create_statement) @xfail_scylla_version_lt(reason='scylladb/scylladb#22677 - Counters are not yet supported with tablets', - oss_scylla_version="7.0", ent_scylla_version="2026.1") + scylla_version="2026.1") def test_counter(self): create_statement = ( "CREATE TABLE {keyspace}.{table} (" @@ -725,7 +725,7 @@ def test_refresh_table_metadata(self): @greaterthanorequalcass30 @xfail_scylla_version_lt(reason='scylladb/scylladb#22677 - Secondary indexes are not supported on base tables with tablets', - oss_scylla_version="7.0", ent_scylla_version="2026.1") + scylla_version="2026.1") def test_refresh_metadata_for_mv(self): """ test for synchronously refreshing materialized view metadata @@ -936,7 +936,7 @@ def test_refresh_user_aggregate_metadata(self): @greaterthanorequalcass30 @requires_collection_indexes @xfail_scylla_version_lt(reason='scylladb/scylladb#22677 - Secondary indexes are not supported on base tables with tablets', - oss_scylla_version="7.0", ent_scylla_version="2026.1") + scylla_version="2026.1") def test_multiple_indices(self): """ test multiple indices on the same column. @@ -971,7 +971,7 @@ def test_multiple_indices(self): @greaterthanorequalcass30 @xfail_scylla_version_lt(reason='scylladb/scylladb#22677 - Secondary indexes are not supported on base tables with tablets', - oss_scylla_version="7.0", ent_scylla_version="2026.1") + scylla_version="2026.1") def test_table_extensions(self): s = self.session ks = self.keyspace_name @@ -1204,8 +1204,8 @@ def test_export_keyspace_schema_udts(self): cluster.shutdown() @greaterthancass21 - @xfail_scylla_version_lt(reason='scylladb/scylladb#10707 - Column name in CREATE INDEX is not quoted', - scylla_version="2023.1.1") + @xfail_scylla_version_lt(reason='scylladb/scylladb#22677 - Secondary indexes are not supported on base tables with tablets', + scylla_version="2026.1") def test_case_sensitivity(self): """ Test that names that need to be escaped in CREATE statements are @@ -1465,6 +1465,8 @@ def create_basic_table(self): def drop_basic_table(self): self.session.execute("DROP TABLE %s" % self.table_name) + @xfail_scylla_version_lt(reason='scylladb/scylladb#22677 - Secondary indexes are not supported on base tables with tablets', + scylla_version="2026.1") def test_index_updates(self): self.create_basic_table() @@ -1506,6 +1508,8 @@ def test_index_updates(self): assert 'a_idx' not in ks_meta.indexes assert 'b_idx' not in ks_meta.indexes + @xfail_scylla_version_lt(reason='scylladb/scylladb#22677 - Secondary indexes are not supported on base tables with tablets', + scylla_version="2026.1") def test_index_follows_alter(self): self.create_basic_table() @@ -2047,6 +2051,8 @@ def test_bad_table(self): assert m._exc_info[0] is self.BadMetaException assert "/*\nWarning:" in m.export_as_string() + @xfail_scylla_version_lt(reason='scylladb/scylladb#22677 - Secondary indexes are not supported on base tables with tablets', + scylla_version="2026.1") def test_bad_index(self): self.session.execute('CREATE TABLE %s (k int PRIMARY KEY, v int)' % self.function_name) self.session.execute('CREATE INDEX ON %s(v)' % self.function_name) @@ -2138,6 +2144,8 @@ def test_dct_alias(self): @greaterthanorequalcass30 +@xfail_scylla_version_lt(reason='scylladb/scylladb#22677 - Secondary indexes are not supported on base tables with tablets', + scylla_version="2026.1") class MaterializedViewMetadataTestSimple(BasicSharedKeyspaceUnitTestCase): def setUp(self): @@ -2226,6 +2234,8 @@ def test_materialized_view_metadata_drop(self): @greaterthanorequalcass30 +@xfail_scylla_version_lt(reason='scylladb/scylladb#22677 - Secondary indexes are not supported on base tables with tablets', + scylla_version="2026.1") class MaterializedViewMetadataTestComplex(BasicSegregatedKeyspaceUnitTestCase): def test_create_view_metadata(self): """ diff --git a/tests/integration/standard/test_query.py b/tests/integration/standard/test_query.py index 4f460459c0..5ae9242ac0 100644 --- a/tests/integration/standard/test_query.py +++ b/tests/integration/standard/test_query.py @@ -1166,6 +1166,8 @@ def test_inherit_first_rk_prepared_param(self): @greaterthanorequalcass30 +@xfail_scylla_version_lt(reason='scylladb/scylladb#22677 - Materialized views and secondary indexes are not supported on base tables with tablets.', + scylla_version='2026.1') class MaterializedViewQueryTest(BasicSharedKeyspaceUnitTestCase): def test_mv_filtering(self): From fe2a9432bbc8f77cd22f215d54ef9ac57b578b8f Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Tue, 5 May 2026 08:59:48 +0200 Subject: [PATCH 072/200] test_replicas_are_queried: use dedicated keyspace with RF=1 and tablets disabled --- tests/integration/standard/test_cluster.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/tests/integration/standard/test_cluster.py b/tests/integration/standard/test_cluster.py index 15e525f43c..00ea11ea27 100644 --- a/tests/integration/standard/test_cluster.py +++ b/tests/integration/standard/test_cluster.py @@ -1195,27 +1195,35 @@ def test_replicas_are_queried(self): Then using HostFilterPolicy the replica is excluded from the considered hosts. By checking the trace we verify that there are no more replicas. + Requires tablets feature disabled. + @since 3.5 @jira_ticket PYTHON-653 @expected_result the replicas are queried for HostFilterPolicy @test_category metadata """ + ks_name = 'test_replicas_queried_ks' queried_hosts = set() tap_profile = ExecutionProfile( load_balancing_policy=TokenAwarePolicy(RoundRobinPolicy()) ) with TestCluster(execution_profiles={EXEC_PROFILE_DEFAULT: tap_profile}) as cluster: session = cluster.connect(wait_for_all_pools=True) + session.execute("DROP KEYSPACE IF EXISTS {}".format(ks_name)) + session.execute( + "CREATE KEYSPACE {} WITH replication = {{'class': 'NetworkTopologyStrategy', " + "'replication_factor': '1'}} AND tablets = {{'enabled': false}}".format(ks_name) + ) session.execute(''' - CREATE TABLE test1rf.table_with_big_key ( + CREATE TABLE {}.table_with_big_key ( k1 int, k2 int, k3 int, k4 int, - PRIMARY KEY((k1, k2, k3), k4))''') - prepared = session.prepare("""SELECT * from test1rf.table_with_big_key - WHERE k1 = ? AND k2 = ? AND k3 = ? AND k4 = ?""") + PRIMARY KEY((k1, k2, k3), k4))'''.format(ks_name)) + prepared = session.prepare("""SELECT * from {}.table_with_big_key + WHERE k1 = ? AND k2 = ? AND k3 = ? AND k4 = ?""".format(ks_name)) for i in range(10): result = session.execute(prepared, (i, i, i, i), trace=True) trace = result.response_future.get_query_trace(query_cl=ConsistencyLevel.ALL) @@ -1234,14 +1242,14 @@ def test_replicas_are_queried(self): execution_profiles={EXEC_PROFILE_DEFAULT: hfp_profile}) as cluster: session = cluster.connect(wait_for_all_pools=True) - prepared = session.prepare("""SELECT * from test1rf.table_with_big_key - WHERE k1 = ? AND k2 = ? AND k3 = ? AND k4 = ?""") + prepared = session.prepare("""SELECT * from {}.table_with_big_key + WHERE k1 = ? AND k2 = ? AND k3 = ? AND k4 = ?""".format(ks_name)) for _ in range(10): result = session.execute(prepared, (last_i, last_i, last_i, last_i), trace=True) trace = result.response_future.get_query_trace(query_cl=ConsistencyLevel.ALL) self._assert_replica_queried(trace, only_replicas=False) - session.execute('''DROP TABLE test1rf.table_with_big_key''') + session.execute('DROP KEYSPACE {}'.format(ks_name)) @greaterthanorequalcass30 @lessthanorequalcass40 From b9813e7be1dc139595dd977cf05251431b3ec716 Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Sun, 29 Mar 2026 10:05:23 +0300 Subject: [PATCH 073/200] ci: update Scylla test version from 2025.2 to 2026.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The integration test suite was pinned to release:2025.2 which is no longer the latest LTS branch. Update to release:2026.1 so CI covers the newest ScyllaDB features and catches regressions earlier. Tests gated by @skip_scylla_version_lt(2026.1.0) — such as the client_routes tests — will now actually execute in CI. --- .github/workflows/integration-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index fde1ab3e1d..61261aadf8 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -38,7 +38,7 @@ jobs: if: "!contains(github.event.pull_request.labels.*.name, 'disable-integration-tests')" runs-on: ubuntu-24.04 env: - SCYLLA_VERSION: release:2025.2 + SCYLLA_VERSION: release:2026.1 strategy: fail-fast: false matrix: From fb13815c7622f5ec65e8655d667171dd72503afe Mon Sep 17 00:00:00 2001 From: Roy Dahan Date: Mon, 11 May 2026 19:53:47 +0300 Subject: [PATCH 074/200] Replace SimpleStrategy with NetworkTopologyStrategy across codebase ScyllaDB has dropped support for SimpleStrategy. Update all CQL statements, test fixtures, examples, benchmarks, and management utilities to use NetworkTopologyStrategy instead. The SimpleStrategy class definition in cassandra/metadata.py is preserved for backward compatibility with Cassandra clusters. --- benchmarks/base.py | 2 +- cassandra/cqlengine/management.py | 6 +- docs/scylla-specific.rst | 2 +- .../execute_async_with_queue.py | 2 +- .../execute_with_threads.py | 2 +- examples/example_core.py | 2 +- .../cqlengine/connections/test_connection.py | 4 +- tests/integration/long/test_failure_types.py | 2 +- tests/integration/long/test_policies.py | 2 +- tests/integration/long/test_schema.py | 12 ++-- tests/integration/long/test_ssl.py | 4 +- tests/integration/long/utils.py | 2 +- .../simulacron/test_empty_column.py | 4 +- tests/unit/advanced/test_metadata.py | 4 +- tests/unit/test_metadata.py | 66 +++++++++---------- 15 files changed, 58 insertions(+), 58 deletions(-) diff --git a/benchmarks/base.py b/benchmarks/base.py index d9cd004474..3922eefad5 100644 --- a/benchmarks/base.py +++ b/benchmarks/base.py @@ -97,7 +97,7 @@ def setup(options): try: session.execute(""" CREATE KEYSPACE %s - WITH replication = { 'class': 'SimpleStrategy', 'replication_factor': '2' } + WITH replication = { 'class': 'NetworkTopologyStrategy', 'replication_factor': '2' } """ % options.keyspace) log.debug("Setting keyspace...") diff --git a/cassandra/cqlengine/management.py b/cassandra/cqlengine/management.py index d6dc44119a..684bc50b8a 100644 --- a/cassandra/cqlengine/management.py +++ b/cassandra/cqlengine/management.py @@ -56,7 +56,7 @@ def _get_context(keyspaces, connections): def create_keyspace_simple(name, replication_factor, durable_writes=True, connections=None): """ - Creates a keyspace with SimpleStrategy for replica placement + Creates a keyspace with NetworkTopologyStrategy for replica placement If the keyspace already exists, it will not be modified. @@ -66,11 +66,11 @@ def create_keyspace_simple(name, replication_factor, durable_writes=True, connec *There are plans to guard schema-modifying functions with an environment-driven conditional.* :param str name: name of keyspace to create - :param int replication_factor: keyspace replication factor, used with :attr:`~.SimpleStrategy` + :param int replication_factor: keyspace replication factor, used with :attr:`~.NetworkTopologyStrategy` :param bool durable_writes: Write log is bypassed if set to False :param list connections: List of connection names """ - _create_keyspace(name, durable_writes, 'SimpleStrategy', + _create_keyspace(name, durable_writes, 'NetworkTopologyStrategy', {'replication_factor': replication_factor}, connections=connections) diff --git a/docs/scylla-specific.rst b/docs/scylla-specific.rst index e9fe695f8f..4b28781f1c 100644 --- a/docs/scylla-specific.rst +++ b/docs/scylla-specific.rst @@ -91,7 +91,7 @@ New Error Types session = cluster.connect() session.execute(""" CREATE KEYSPACE IF NOT EXISTS keyspace1 - WITH replication = {'class': 'SimpleStrategy', 'replication_factor': '1'} + WITH replication = {'class': 'NetworkTopologyStrategy', 'replication_factor': '1'} """) session.execute("USE keyspace1") diff --git a/examples/concurrent_executions/execute_async_with_queue.py b/examples/concurrent_executions/execute_async_with_queue.py index 72d2c101cb..794ac78818 100644 --- a/examples/concurrent_executions/execute_async_with_queue.py +++ b/examples/concurrent_executions/execute_async_with_queue.py @@ -31,7 +31,7 @@ session = cluster.connect() session.execute(("CREATE KEYSPACE IF NOT EXISTS examples " - "WITH replication = {'class': 'SimpleStrategy', 'replication_factor': '1' }")) + "WITH replication = {'class': 'NetworkTopologyStrategy', 'replication_factor': '1' }")) session.execute("USE examples") session.execute("CREATE TABLE IF NOT EXISTS tbl_sample_kv (id uuid, value text, PRIMARY KEY (id))") prepared_insert = session.prepare("INSERT INTO tbl_sample_kv (id, value) VALUES (?, ?)") diff --git a/examples/concurrent_executions/execute_with_threads.py b/examples/concurrent_executions/execute_with_threads.py index e3c80f5d6b..70893bd5be 100644 --- a/examples/concurrent_executions/execute_with_threads.py +++ b/examples/concurrent_executions/execute_with_threads.py @@ -34,7 +34,7 @@ session = cluster.connect() session.execute(("CREATE KEYSPACE IF NOT EXISTS examples " - "WITH replication = {'class': 'SimpleStrategy', 'replication_factor': '1' }")) + "WITH replication = {'class': 'NetworkTopologyStrategy', 'replication_factor': '1' }")) session.execute("USE examples") session.execute("CREATE TABLE IF NOT EXISTS tbl_sample_kv (id uuid, value text, PRIMARY KEY (id))") prepared_insert = session.prepare("INSERT INTO tbl_sample_kv (id, value) VALUES (?, ?)") diff --git a/examples/example_core.py b/examples/example_core.py index 01c766e109..ec41ca7fd5 100644 --- a/examples/example_core.py +++ b/examples/example_core.py @@ -36,7 +36,7 @@ def main(): log.info("creating keyspace...") session.execute(""" CREATE KEYSPACE IF NOT EXISTS %s - WITH replication = { 'class': 'SimpleStrategy', 'replication_factor': '2' } + WITH replication = { 'class': 'NetworkTopologyStrategy', 'replication_factor': '2' } """ % KEYSPACE) log.info("setting keyspace...") diff --git a/tests/integration/cqlengine/connections/test_connection.py b/tests/integration/cqlengine/connections/test_connection.py index 78d5133e63..640c953285 100644 --- a/tests/integration/cqlengine/connections/test_connection.py +++ b/tests/integration/cqlengine/connections/test_connection.py @@ -76,9 +76,9 @@ def setUpClass(cls): super(SeveralConnectionsTest, cls).setUpClass() cls.setup_cluster = TestCluster() cls.setup_session = cls.setup_cluster.connect() - ddl = "CREATE KEYSPACE {0} WITH replication = {{'class': 'SimpleStrategy', 'replication_factor': '{1}'}}".format(cls.keyspace1, 1) + ddl = "CREATE KEYSPACE {0} WITH replication = {{'class': 'NetworkTopologyStrategy', 'replication_factor': '{1}'}}".format(cls.keyspace1, 1) execute_with_long_wait_retry(cls.setup_session, ddl) - ddl = "CREATE KEYSPACE {0} WITH replication = {{'class': 'SimpleStrategy', 'replication_factor': '{1}'}}".format(cls.keyspace2, 1) + ddl = "CREATE KEYSPACE {0} WITH replication = {{'class': 'NetworkTopologyStrategy', 'replication_factor': '{1}'}}".format(cls.keyspace2, 1) execute_with_long_wait_retry(cls.setup_session, ddl) @classmethod diff --git a/tests/integration/long/test_failure_types.py b/tests/integration/long/test_failure_types.py index beb10f02c0..04d75555f5 100644 --- a/tests/integration/long/test_failure_types.py +++ b/tests/integration/long/test_failure_types.py @@ -187,7 +187,7 @@ def test_write_failures_from_coordinator(self): self._perform_cql_statement( """ CREATE KEYSPACE testksfail - WITH replication = {'class': 'SimpleStrategy', 'replication_factor': '3'} + WITH replication = {'class': 'NetworkTopologyStrategy', 'replication_factor': '3'} """, consistency_level=ConsistencyLevel.ALL, expected_exception=None) # create table diff --git a/tests/integration/long/test_policies.py b/tests/integration/long/test_policies.py index ab8d125ab1..5cada34d8b 100644 --- a/tests/integration/long/test_policies.py +++ b/tests/integration/long/test_policies.py @@ -48,7 +48,7 @@ def test_should_rethrow_on_unvailable_with_default_policy_if_cas(self): cluster = TestCluster(execution_profiles={EXEC_PROFILE_DEFAULT: ep}) session = cluster.connect() - session.execute("CREATE KEYSPACE test_retry_policy_cas WITH replication = {'class':'SimpleStrategy','replication_factor': 3};") + session.execute("CREATE KEYSPACE test_retry_policy_cas WITH replication = {'class':'NetworkTopologyStrategy','replication_factor': 3};") session.execute("CREATE TABLE test_retry_policy_cas.t (id int PRIMARY KEY, data text);") session.execute('INSERT INTO test_retry_policy_cas.t ("id", "data") VALUES (%(0)s, %(1)s)', {'0': 42, '1': 'testing'}) diff --git a/tests/integration/long/test_schema.py b/tests/integration/long/test_schema.py index 3b4dcd33d5..d60ff775c4 100644 --- a/tests/integration/long/test_schema.py +++ b/tests/integration/long/test_schema.py @@ -57,7 +57,7 @@ def test_recreates(self): log.debug(drop) execute_until_pass(session, drop) - create = "CREATE KEYSPACE {0} WITH replication = {{'class': 'SimpleStrategy', 'replication_factor': 3}}".format(keyspace) + create = "CREATE KEYSPACE {0} WITH replication = {{'class': 'NetworkTopologyStrategy', 'replication_factor': 3}}".format(keyspace) log.debug(create) execute_until_pass(session, create) @@ -82,7 +82,7 @@ def test_for_schema_disagreements_different_keyspaces(self): session = self.session for i in range(30): - execute_until_pass(session, "CREATE KEYSPACE test_{0} WITH replication = {{'class': 'SimpleStrategy', 'replication_factor': 1}}".format(i)) + execute_until_pass(session, "CREATE KEYSPACE test_{0} WITH replication = {{'class': 'NetworkTopologyStrategy', 'replication_factor': 1}}".format(i)) execute_until_pass(session, "CREATE TABLE test_{0}.cf (key int PRIMARY KEY, value int)".format(i)) for j in range(100): @@ -100,10 +100,10 @@ def test_for_schema_disagreements_same_keyspace(self): for i in range(30): try: - execute_until_pass(session, "CREATE KEYSPACE test WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1}") + execute_until_pass(session, "CREATE KEYSPACE test WITH replication = {'class': 'NetworkTopologyStrategy', 'replication_factor': 1}") except AlreadyExists: execute_until_pass(session, "DROP KEYSPACE test") - execute_until_pass(session, "CREATE KEYSPACE test WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1}") + execute_until_pass(session, "CREATE KEYSPACE test WITH replication = {'class': 'NetworkTopologyStrategy', 'replication_factor': 1}") execute_until_pass(session, "CREATE TABLE test.cf (key int PRIMARY KEY, value int)") @@ -132,7 +132,7 @@ def test_for_schema_disagreement_attribute(self): cluster = TestCluster(max_schema_agreement_wait=0.001) session = cluster.connect(wait_for_all_pools=True) - rs = session.execute("CREATE KEYSPACE test_schema_disagreement WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 3}") + rs = session.execute("CREATE KEYSPACE test_schema_disagreement WITH replication = {'class': 'NetworkTopologyStrategy', 'replication_factor': 3}") self.check_and_wait_for_agreement(session, rs, False) rs = session.execute(SimpleStatement("CREATE TABLE test_schema_disagreement.cf (key int PRIMARY KEY, value int)", consistency_level=ConsistencyLevel.ALL)) @@ -144,7 +144,7 @@ def test_for_schema_disagreement_attribute(self): # These should have schema agreement cluster = TestCluster(max_schema_agreement_wait=100) session = cluster.connect() - rs = session.execute("CREATE KEYSPACE test_schema_disagreement WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 3}") + rs = session.execute("CREATE KEYSPACE test_schema_disagreement WITH replication = {'class': 'NetworkTopologyStrategy', 'replication_factor': 3}") self.check_and_wait_for_agreement(session, rs, True) rs = session.execute(SimpleStatement("CREATE TABLE test_schema_disagreement.cf (key int PRIMARY KEY, value int)", consistency_level=ConsistencyLevel.ALL)) diff --git a/tests/integration/long/test_ssl.py b/tests/integration/long/test_ssl.py index 56dc6a5c2d..0170f56fa1 100644 --- a/tests/integration/long/test_ssl.py +++ b/tests/integration/long/test_ssl.py @@ -116,7 +116,7 @@ def validate_ssl_options(**kwargs): # attempt a few simple commands. insert_keyspace = """CREATE KEYSPACE ssltest - WITH replication = {'class': 'SimpleStrategy', 'replication_factor': '3'} + WITH replication = {'class': 'NetworkTopologyStrategy', 'replication_factor': '3'} """ statement = SimpleStatement(insert_keyspace) statement.consistency_level = 3 @@ -369,7 +369,7 @@ def test_ssl_want_write_errors_are_retried(self): except: pass session.execute( - "CREATE KEYSPACE ssl_error_test WITH replication = {'class':'SimpleStrategy','replication_factor':1};") + "CREATE KEYSPACE ssl_error_test WITH replication = {'class':'NetworkTopologyStrategy','replication_factor':1};") session.execute("CREATE TABLE ssl_error_test.big_text (id uuid PRIMARY KEY, data text);") params = { diff --git a/tests/integration/long/utils.py b/tests/integration/long/utils.py index 93464df8ff..ba9351828e 100644 --- a/tests/integration/long/utils.py +++ b/tests/integration/long/utils.py @@ -63,7 +63,7 @@ def create_schema(cluster, session, keyspace, simple_strategy=True, if simple_strategy: ddl = "CREATE KEYSPACE %s WITH replication" \ - " = {'class': 'SimpleStrategy', 'replication_factor': '%s'}" + " = {'class': 'NetworkTopologyStrategy', 'replication_factor': '%s'}" session.execute(ddl % (keyspace, replication_factor), timeout=10) else: if not replication_strategy: diff --git a/tests/integration/simulacron/test_empty_column.py b/tests/integration/simulacron/test_empty_column.py index 2dbf3985ad..daa9f20fa8 100644 --- a/tests/integration/simulacron/test_empty_column.py +++ b/tests/integration/simulacron/test_empty_column.py @@ -140,9 +140,9 @@ def test_empty_columns_in_system_schema(self): 'delay_in_ms': 0, 'rows': [ { - "strategy_class": "SimpleStrategy", # C* 2.2 + "strategy_class": "NetworkTopologyStrategy", # C* 2.2 "strategy_options": '{}', # C* 2.2 - "replication": {'strategy': 'SimpleStrategy', 'replication_factor': 1}, + "replication": {'strategy': 'NetworkTopologyStrategy', 'replication_factor': 1}, "durable_writes": True, "keyspace_name": "testks" } diff --git a/tests/unit/advanced/test_metadata.py b/tests/unit/advanced/test_metadata.py index 5ccfa5e477..d68a87961d 100644 --- a/tests/unit/advanced/test_metadata.py +++ b/tests/unit/advanced/test_metadata.py @@ -34,8 +34,8 @@ def _create_vertex_metadata(self, label_name='label'): def _create_keyspace_metadata(self, graph_engine): return KeyspaceMetadata( - 'keyspace', True, 'org.apache.cassandra.locator.SimpleStrategy', - {'replication_factor': 1}, graph_engine=graph_engine) + 'keyspace', True, 'org.apache.cassandra.locator.NetworkTopologyStrategy', + {'dc1': 1}, graph_engine=graph_engine) def _create_table_metadata(self, with_vertex=False, with_edge=False): tm = TableMetadataDSE68('keyspace', 'table') diff --git a/tests/unit/test_metadata.py b/tests/unit/test_metadata.py index dcbb840447..15cf283777 100644 --- a/tests/unit/test_metadata.py +++ b/tests/unit/test_metadata.py @@ -25,7 +25,7 @@ from cassandra.marshal import uint16_unpack, uint16_pack from cassandra.metadata import (Murmur3Token, MD5Token, BytesToken, ReplicationStrategy, - NetworkTopologyStrategy, SimpleStrategy, + NetworkTopologyStrategy, LocalStrategy, protect_name, protect_names, protect_value, is_valid_name, UserType, KeyspaceMetadata, get_schema_parser, @@ -96,14 +96,14 @@ def test_replication_strategy(self): assert rs.create('NetworkTopologyStrategy', fake_options_map).dc_replication_factors == NetworkTopologyStrategy(fake_options_map).dc_replication_factors fake_options_map = {'options': 'map'} - assert rs.create('SimpleStrategy', fake_options_map) is None + assert rs.create('NetworkTopologyStrategy', fake_options_map) is None fake_options_map = {'options': 'map'} assert isinstance(rs.create('LocalStrategy', fake_options_map), LocalStrategy) - fake_options_map = {'options': 'map', 'replication_factor': 3} - assert isinstance(rs.create('SimpleStrategy', fake_options_map), SimpleStrategy) - assert rs.create('SimpleStrategy', fake_options_map).replication_factor == SimpleStrategy(fake_options_map).replication_factor + fake_options_map = {'dc1': 3} + assert isinstance(rs.create('NetworkTopologyStrategy', fake_options_map), NetworkTopologyStrategy) + assert rs.create('NetworkTopologyStrategy', fake_options_map).dc_replication_factors == NetworkTopologyStrategy(fake_options_map).dc_replication_factors assert rs.create('xxxxxxxx', fake_options_map) == _UnknownStrategy('xxxxxxxx', fake_options_map) @@ -113,38 +113,38 @@ def test_replication_strategy(self): rs.export_for_schema() def test_simple_replication_type_parsing(self): - """ Test equality between passing numeric and string replication factor for simple strategy """ + """ Test equality between passing numeric and string replication factor for NTS """ rs = ReplicationStrategy() - simple_int = rs.create('SimpleStrategy', {'replication_factor': 3}) - simple_str = rs.create('SimpleStrategy', {'replication_factor': '3'}) + nts_int = rs.create('NetworkTopologyStrategy', {'dc1': 3}) + nts_str = rs.create('NetworkTopologyStrategy', {'dc1': '3'}) - assert simple_int.export_for_schema() == simple_str.export_for_schema() - assert simple_int == simple_str + assert nts_int.export_for_schema() == nts_str.export_for_schema() + assert nts_int == nts_str # make token replica map ring = [MD5Token(0), MD5Token(1), MD5Token(2)] - hosts = [Host('dc1.{}'.format(host), SimpleConvictionPolicy, host_id=uuid.uuid4()) for host in range(3)] + hosts = [Host('dc1.{}'.format(host), SimpleConvictionPolicy, datacenter='dc1', rack='rack1', host_id=uuid.uuid4()) for host in range(3)] token_to_host = dict(zip(ring, hosts)) - assert simple_int.make_token_replica_map(token_to_host, ring) == simple_str.make_token_replica_map(token_to_host, ring) + assert nts_int.make_token_replica_map(token_to_host, ring) == nts_str.make_token_replica_map(token_to_host, ring) def test_transient_replication_parsing(self): - """ Test that we can PARSE a transient replication factor for SimpleStrategy """ + """ Test that we can PARSE a transient replication factor for NetworkTopologyStrategy """ rs = ReplicationStrategy() - simple_transient = rs.create('SimpleStrategy', {'replication_factor': '3/1'}) - assert simple_transient.replication_factor_info == ReplicationFactor(3, 1) - assert simple_transient.replication_factor == 2 - assert "'replication_factor': '3/1'" in simple_transient.export_for_schema() + nts_transient = rs.create('NetworkTopologyStrategy', {'dc1': '3/1'}) + assert nts_transient.dc_replication_factors_info['dc1'] == ReplicationFactor(3, 1) + assert nts_transient.dc_replication_factors['dc1'] == 2 + assert "'dc1': '3/1'" in nts_transient.export_for_schema() - simple_str = rs.create('SimpleStrategy', {'replication_factor': '2'}) - assert simple_transient != simple_str + nts_str = rs.create('NetworkTopologyStrategy', {'dc1': '2'}) + assert nts_transient != nts_str # make token replica map ring = [MD5Token(0), MD5Token(1), MD5Token(2)] - hosts = [Host('dc1.{}'.format(host), SimpleConvictionPolicy, host_id=uuid.uuid4()) for host in range(3)] + hosts = [Host('dc1.{}'.format(host), SimpleConvictionPolicy, datacenter='dc1', rack='rack1', host_id=uuid.uuid4()) for host in range(3)] token_to_host = dict(zip(ring, hosts)) - assert simple_transient.make_token_replica_map(token_to_host, ring) == simple_str.make_token_replica_map(token_to_host, ring) + assert nts_transient.make_token_replica_map(token_to_host, ring) == nts_str.make_token_replica_map(token_to_host, ring) def test_nts_replication_parsing(self): """ Test equality between passing numeric and string replication factor for NTS """ @@ -318,9 +318,9 @@ def test_nts_export_for_schema(self): assert "{'class': 'NetworkTopologyStrategy', 'dc1': '1', 'dc2': '2'}" == strategy.export_for_schema() def test_simple_strategy_make_token_replica_map(self): - host1 = Host('1', SimpleConvictionPolicy, host_id=uuid.uuid4()) - host2 = Host('2', SimpleConvictionPolicy, host_id=uuid.uuid4()) - host3 = Host('3', SimpleConvictionPolicy, host_id=uuid.uuid4()) + host1 = Host('1', SimpleConvictionPolicy, datacenter='dc1', rack='rack1', host_id=uuid.uuid4()) + host2 = Host('2', SimpleConvictionPolicy, datacenter='dc1', rack='rack1', host_id=uuid.uuid4()) + host3 = Host('3', SimpleConvictionPolicy, datacenter='dc1', rack='rack1', host_id=uuid.uuid4()) token_to_host_owner = { MD5Token(0): host1, MD5Token(100): host2, @@ -328,23 +328,23 @@ def test_simple_strategy_make_token_replica_map(self): } ring = [MD5Token(0), MD5Token(100), MD5Token(200)] - rf1_replicas = SimpleStrategy({'replication_factor': '1'}).make_token_replica_map(token_to_host_owner, ring) + rf1_replicas = NetworkTopologyStrategy({'dc1': '1'}).make_token_replica_map(token_to_host_owner, ring) assertCountEqual(rf1_replicas[MD5Token(0)], [host1]) assertCountEqual(rf1_replicas[MD5Token(100)], [host2]) assertCountEqual(rf1_replicas[MD5Token(200)], [host3]) - rf2_replicas = SimpleStrategy({'replication_factor': '2'}).make_token_replica_map(token_to_host_owner, ring) + rf2_replicas = NetworkTopologyStrategy({'dc1': '2'}).make_token_replica_map(token_to_host_owner, ring) assertCountEqual(rf2_replicas[MD5Token(0)], [host1, host2]) assertCountEqual(rf2_replicas[MD5Token(100)], [host2, host3]) assertCountEqual(rf2_replicas[MD5Token(200)], [host3, host1]) - rf3_replicas = SimpleStrategy({'replication_factor': '3'}).make_token_replica_map(token_to_host_owner, ring) + rf3_replicas = NetworkTopologyStrategy({'dc1': '3'}).make_token_replica_map(token_to_host_owner, ring) assertCountEqual(rf3_replicas[MD5Token(0)], [host1, host2, host3]) assertCountEqual(rf3_replicas[MD5Token(100)], [host2, host3, host1]) assertCountEqual(rf3_replicas[MD5Token(200)], [host3, host1, host2]) def test_ss_equals(self): - assert SimpleStrategy({'replication_factor': '1'}) != NetworkTopologyStrategy({'dc1': 2}) + assert NetworkTopologyStrategy({'dc1': '1'}) != NetworkTopologyStrategy({'dc1': 2}) class NameEscapingTest(unittest.TestCase): @@ -409,9 +409,9 @@ def test_is_valid_name(self): class GetReplicasTest(unittest.TestCase): def _get_replicas(self, token_klass): tokens = [token_klass(i) for i in range(0, (2 ** 127 - 1), 2 ** 125)] - hosts = [Host("ip%d" % i, SimpleConvictionPolicy, host_id=uuid.uuid4()) for i in range(len(tokens))] + hosts = [Host("ip%d" % i, SimpleConvictionPolicy, datacenter="dc1", rack="rack1", host_id=uuid.uuid4()) for i in range(len(tokens))] token_to_primary_replica = dict(zip(tokens, hosts)) - keyspace = KeyspaceMetadata("ks", True, "SimpleStrategy", {"replication_factor": "1"}) + keyspace = KeyspaceMetadata("ks", True, "NetworkTopologyStrategy", {"dc1": "1"}) metadata = Mock(spec=Metadata, keyspaces={'ks': keyspace}) token_map = TokenMap(token_klass, token_to_primary_replica, tokens, metadata) @@ -524,13 +524,13 @@ class KeyspaceMetadataTest(unittest.TestCase): def test_export_as_string_user_types(self): keyspace_name = 'test' - keyspace = KeyspaceMetadata(keyspace_name, True, 'SimpleStrategy', dict(replication_factor=3)) + keyspace = KeyspaceMetadata(keyspace_name, True, 'NetworkTopologyStrategy', dict(dc1=3)) keyspace.user_types['a'] = UserType(keyspace_name, 'a', ['one', 'two'], ['c', 'int']) keyspace.user_types['b'] = UserType(keyspace_name, 'b', ['one', 'two', 'three'], ['d', 'int', 'a']) keyspace.user_types['c'] = UserType(keyspace_name, 'c', ['one'], ['int']) keyspace.user_types['d'] = UserType(keyspace_name, 'd', ['one'], ['c']) - assert """CREATE KEYSPACE test WITH replication = {'class': 'SimpleStrategy', 'replication_factor': '3'} AND durable_writes = true; + assert """CREATE KEYSPACE test WITH replication = {'class': 'NetworkTopologyStrategy', 'dc1': '3'} AND durable_writes = true; CREATE TYPE test.c ( one int @@ -662,7 +662,7 @@ class UnicodeIdentifiersTests(unittest.TestCase): name = b'\'_-()"\xc2\xac'.decode('utf-8') def test_keyspace_name(self): - km = KeyspaceMetadata(self.name, False, 'SimpleStrategy', {'replication_factor': 1}) + km = KeyspaceMetadata(self.name, False, 'NetworkTopologyStrategy', {'dc1': 1}) km.export_as_string() def test_table_name(self): From f7d945ff52df0e101c0b15070675cad0500ced11 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 12:57:05 +0000 Subject: [PATCH 075/200] build(deps): bump urllib3 from 2.6.3 to 2.7.0 in /docs Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.6.3 to 2.7.0. - [Release notes](https://github.com/urllib3/urllib3/releases) - [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst) - [Commits](https://github.com/urllib3/urllib3/compare/2.6.3...2.7.0) --- updated-dependencies: - dependency-name: urllib3 dependency-version: 2.7.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- docs/uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/uv.lock b/docs/uv.lock index 56b0841403..515e37abba 100644 --- a/docs/uv.lock +++ b/docs/uv.lock @@ -1067,11 +1067,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] From cf01c3f9973388fc6b7ca8425c37deea6ff00a2f Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Fri, 15 May 2026 11:00:26 +0200 Subject: [PATCH 076/200] tests: use tablets-disabled keyspace instead of xfail for scylladb/scylladb#22677 Tests that previously xfailed on ScyllaDB < 2026.1 due to MVs, secondary indexes, and counters not being supported on tables with tablets now create their keyspace with 'AND tablets = {"enabled": false}' for those older versions, so the tests run and pass rather than being expected to fail. A new helper get_tablets_disabled_ddl_suffix() is added to tests/integration/__init__.py to return the appropriate DDL suffix. --- tests/integration/__init__.py | 11 ++++ .../integration/cqlengine/query/test_named.py | 10 +++- tests/integration/standard/test_metadata.py | 60 +++++++++---------- tests/integration/standard/test_query.py | 11 +++- 4 files changed, 55 insertions(+), 37 deletions(-) diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py index 7d4d47c9a7..5701e5b3da 100644 --- a/tests/integration/__init__.py +++ b/tests/integration/__init__.py @@ -707,6 +707,17 @@ def xfail_scylla_version_lt(reason, scylla_version, *args, **kwargs): return pytest.mark.xfail(current_version < Version(scylla_version), reason=reason, *args, **kwargs) +def get_tablets_disabled_ddl_suffix(scylla_version='2026.1'): + """ + Returns DDL option string for disabling tablets on ScyllaDB versions older than scylla_version. + Used to work around features not yet supported with tablets (e.g. MVs, secondary indexes, counters). + :param scylla_version: str, version from which tablets support the feature + """ + if SCYLLA_VERSION is not None and Version(get_scylla_version(SCYLLA_VERSION)) < Version(scylla_version): + return " AND tablets = {'enabled': false}" + return "" + + def skip_scylla_version_lt(reason, scylla_version): """ Skip tests on scylla versions older than the specified thresholds. diff --git a/tests/integration/cqlengine/query/test_named.py b/tests/integration/cqlengine/query/test_named.py index 4923a8a583..66ba8b973a 100644 --- a/tests/integration/cqlengine/query/test_named.py +++ b/tests/integration/cqlengine/query/test_named.py @@ -27,7 +27,7 @@ from tests.integration.cqlengine.query.test_queryset import BaseQuerySetUsage -from tests.integration import BasicSharedKeyspaceUnitTestCase, greaterthanorequalcass30, requires_collection_indexes, xfail_scylla_version_lt +from tests.integration import BasicSharedKeyspaceUnitTestCase, greaterthanorequalcass30, requires_collection_indexes, get_tablets_disabled_ddl_suffix, execute_with_long_wait_retry import pytest @@ -280,6 +280,12 @@ def test_get_multipleobjects_exception(self): class TestNamedWithMV(BasicSharedKeyspaceUnitTestCase): + @classmethod + def create_keyspace(cls, rf): + ddl = "CREATE KEYSPACE {0} WITH replication = {{'class': 'NetworkTopologyStrategy', 'replication_factor': '{1}'}}{2}".format( + cls.ks_name, rf, get_tablets_disabled_ddl_suffix()) + execute_with_long_wait_retry(cls.session, ddl) + @classmethod def setUpClass(cls): super(TestNamedWithMV, cls).setUpClass() @@ -292,8 +298,6 @@ def tearDownClass(cls): super(TestNamedWithMV, cls).tearDownClass() @greaterthanorequalcass30 - @xfail_scylla_version_lt(reason='scylladb/scylladb#22677 - Materialized views and secondary indexes are not supported on base tables with tablets.', - scylla_version='2026.1') @execute_count(5) def test_named_table_with_mv(self): """ diff --git a/tests/integration/standard/test_metadata.py b/tests/integration/standard/test_metadata.py index 84ec6c9ea5..f5a11dd5fe 100644 --- a/tests/integration/standard/test_metadata.py +++ b/tests/integration/standard/test_metadata.py @@ -45,7 +45,7 @@ lessthancass40, TestCluster, requires_java_udf, requires_composite_type, requires_collection_indexes, SCYLLA_VERSION, xfail_scylla, xfail_scylla_version_lt, - requirescompactstorage) + requirescompactstorage, get_tablets_disabled_ddl_suffix, execute_with_long_wait_retry) from tests.util import wait_until, assertRegex, assertDictEqual, assertListEqual, assert_startswith_diff @@ -141,6 +141,12 @@ def test_bad_contact_point(self): class SchemaMetadataTests(BasicSegregatedKeyspaceUnitTestCase): + @classmethod + def create_keyspace(cls, rf): + ddl = "CREATE KEYSPACE {0} WITH replication = {{'class': 'NetworkTopologyStrategy', 'replication_factor': '{1}'}}{2}".format( + cls.ks_name, rf, get_tablets_disabled_ddl_suffix()) + execute_with_long_wait_retry(cls.session, ddl) + def test_schema_metadata_disable(self): """ Checks to ensure that schema metadata_enabled, and token_metadata_enabled @@ -448,8 +454,6 @@ def test_dense_compact_storage(self): tablemeta = self.get_table_metadata() self.check_create_statement(tablemeta, create_statement) - @xfail_scylla_version_lt(reason='scylladb/scylladb#22677 - Counters are not yet supported with tablets', - scylla_version="2026.1") def test_counter(self): create_statement = ( "CREATE TABLE {keyspace}.{table} (" @@ -724,8 +728,6 @@ def test_refresh_table_metadata(self): cluster2.shutdown() @greaterthanorequalcass30 - @xfail_scylla_version_lt(reason='scylladb/scylladb#22677 - Secondary indexes are not supported on base tables with tablets', - scylla_version="2026.1") def test_refresh_metadata_for_mv(self): """ test for synchronously refreshing materialized view metadata @@ -935,8 +937,6 @@ def test_refresh_user_aggregate_metadata(self): @greaterthanorequalcass30 @requires_collection_indexes - @xfail_scylla_version_lt(reason='scylladb/scylladb#22677 - Secondary indexes are not supported on base tables with tablets', - scylla_version="2026.1") def test_multiple_indices(self): """ test multiple indices on the same column. @@ -970,8 +970,6 @@ def test_multiple_indices(self): assert index_2.keyspace_name == "schemametadatatests" @greaterthanorequalcass30 - @xfail_scylla_version_lt(reason='scylladb/scylladb#22677 - Secondary indexes are not supported on base tables with tablets', - scylla_version="2026.1") def test_table_extensions(self): s = self.session ks = self.keyspace_name @@ -1204,8 +1202,6 @@ def test_export_keyspace_schema_udts(self): cluster.shutdown() @greaterthancass21 - @xfail_scylla_version_lt(reason='scylladb/scylladb#22677 - Secondary indexes are not supported on base tables with tablets', - scylla_version="2026.1") def test_case_sensitivity(self): """ Test that names that need to be escaped in CREATE statements are @@ -1218,10 +1214,9 @@ def test_case_sensitivity(self): cfname = 'AnInterestingTable' session.execute("DROP KEYSPACE IF EXISTS {0}".format(ksname)) - session.execute(""" - CREATE KEYSPACE "%s" - WITH replication = {'class': 'NetworkTopologyStrategy', 'replication_factor': '1'} - """ % (ksname,)) + session.execute( + ("CREATE KEYSPACE \"%s\" WITH replication = {'class': 'NetworkTopologyStrategy', 'replication_factor': '1'}" + + get_tablets_disabled_ddl_suffix()) % (ksname,)) session.execute(""" CREATE TABLE "%s"."%s" ( k int, @@ -1442,11 +1437,9 @@ def setup_class(cls): if cls.keyspace_name in cls.cluster.metadata.keyspaces: cls.session.execute("DROP KEYSPACE %s" % cls.keyspace_name) - cls.session.execute( - """ - CREATE KEYSPACE %s - WITH replication = {'class': 'NetworkTopologyStrategy', 'replication_factor': '1'}; - """ % cls.keyspace_name) + ddl = ("CREATE KEYSPACE %s WITH replication = {'class': 'NetworkTopologyStrategy', 'replication_factor': '1'}" + + get_tablets_disabled_ddl_suffix()) + cls.session.execute(ddl % cls.keyspace_name) cls.session.set_keyspace(cls.keyspace_name) except Exception: cls.cluster.shutdown() @@ -1465,8 +1458,6 @@ def create_basic_table(self): def drop_basic_table(self): self.session.execute("DROP TABLE %s" % self.table_name) - @xfail_scylla_version_lt(reason='scylladb/scylladb#22677 - Secondary indexes are not supported on base tables with tablets', - scylla_version="2026.1") def test_index_updates(self): self.create_basic_table() @@ -1508,8 +1499,6 @@ def test_index_updates(self): assert 'a_idx' not in ks_meta.indexes assert 'b_idx' not in ks_meta.indexes - @xfail_scylla_version_lt(reason='scylladb/scylladb#22677 - Secondary indexes are not supported on base tables with tablets', - scylla_version="2026.1") def test_index_follows_alter(self): self.create_basic_table() @@ -2019,7 +2008,8 @@ def setup_class(cls): cls.cluster = TestCluster() cls.keyspace_name = cls.__name__.lower() cls.session = cls.cluster.connect() - cls.session.execute("CREATE KEYSPACE %s WITH replication = {'class': 'NetworkTopologyStrategy', 'replication_factor': '1'}" % cls.keyspace_name) + ddl = "CREATE KEYSPACE %s WITH replication = {'class': 'NetworkTopologyStrategy', 'replication_factor': '1'}" + get_tablets_disabled_ddl_suffix() + cls.session.execute(ddl % cls.keyspace_name) cls.session.set_keyspace(cls.keyspace_name) connection = cls.cluster.control_connection._connection @@ -2051,8 +2041,6 @@ def test_bad_table(self): assert m._exc_info[0] is self.BadMetaException assert "/*\nWarning:" in m.export_as_string() - @xfail_scylla_version_lt(reason='scylladb/scylladb#22677 - Secondary indexes are not supported on base tables with tablets', - scylla_version="2026.1") def test_bad_index(self): self.session.execute('CREATE TABLE %s (k int PRIMARY KEY, v int)' % self.function_name) self.session.execute('CREATE INDEX ON %s(v)' % self.function_name) @@ -2144,10 +2132,15 @@ def test_dct_alias(self): @greaterthanorequalcass30 -@xfail_scylla_version_lt(reason='scylladb/scylladb#22677 - Secondary indexes are not supported on base tables with tablets', - scylla_version="2026.1") class MaterializedViewMetadataTestSimple(BasicSharedKeyspaceUnitTestCase): + @classmethod + def create_keyspace(cls, rf): + ddl = "CREATE KEYSPACE {0} WITH replication = {{'class': 'NetworkTopologyStrategy', 'replication_factor': '{1}'}}{2}".format( + cls.ks_name, rf, get_tablets_disabled_ddl_suffix()) + execute_with_long_wait_retry(cls.session, ddl) + + def setUp(self): self.session.execute("CREATE TABLE {0}.{1} (pk int PRIMARY KEY, c int)".format(self.keyspace_name, self.function_table_name)) self.session.execute( @@ -2234,9 +2227,14 @@ def test_materialized_view_metadata_drop(self): @greaterthanorequalcass30 -@xfail_scylla_version_lt(reason='scylladb/scylladb#22677 - Secondary indexes are not supported on base tables with tablets', - scylla_version="2026.1") class MaterializedViewMetadataTestComplex(BasicSegregatedKeyspaceUnitTestCase): + + @classmethod + def create_keyspace(cls, rf): + ddl = "CREATE KEYSPACE {0} WITH replication = {{'class': 'NetworkTopologyStrategy', 'replication_factor': '{1}'}}{2}".format( + cls.ks_name, rf, get_tablets_disabled_ddl_suffix()) + execute_with_long_wait_retry(cls.session, ddl) + def test_create_view_metadata(self): """ test to ensure that materialized view metadata is properly constructed diff --git a/tests/integration/standard/test_query.py b/tests/integration/standard/test_query.py index 5ae9242ac0..210f6dacb1 100644 --- a/tests/integration/standard/test_query.py +++ b/tests/integration/standard/test_query.py @@ -26,7 +26,8 @@ from cassandra.policies import HostDistance, RoundRobinPolicy, WhiteListRoundRobinPolicy from tests.integration import use_singledc, PROTOCOL_VERSION, BasicSharedKeyspaceUnitTestCase, \ greaterthanprotocolv3, MockLoggingHandler, get_supported_protocol_versions, local, get_cluster, setup_keyspace, \ - USE_CASS_EXTERNAL, greaterthanorequalcass40, TestCluster, xfail_scylla, xfail_scylla_version_lt + USE_CASS_EXTERNAL, greaterthanorequalcass40, TestCluster, xfail_scylla, xfail_scylla_version_lt, \ + get_tablets_disabled_ddl_suffix, execute_with_long_wait_retry from tests import notwindows from tests.integration import greaterthanorequalcass30, get_node from tests.util import assertListEqual, wait_until @@ -1166,10 +1167,14 @@ def test_inherit_first_rk_prepared_param(self): @greaterthanorequalcass30 -@xfail_scylla_version_lt(reason='scylladb/scylladb#22677 - Materialized views and secondary indexes are not supported on base tables with tablets.', - scylla_version='2026.1') class MaterializedViewQueryTest(BasicSharedKeyspaceUnitTestCase): + @classmethod + def create_keyspace(cls, rf): + ddl = "CREATE KEYSPACE {0} WITH replication = {{'class': 'NetworkTopologyStrategy', 'replication_factor': '{1}'}}{2}".format( + cls.ks_name, rf, get_tablets_disabled_ddl_suffix()) + execute_with_long_wait_retry(cls.session, ddl) + def test_mv_filtering(self): """ Test to ensure that cql filtering where clauses are properly supported in the python driver. From a0eb30421c583d2f8985764d096148240c2ed2cc Mon Sep 17 00:00:00 2001 From: Roy Dahan Date: Mon, 11 May 2026 21:10:57 +0300 Subject: [PATCH 077/200] asyncio: fix SSL connections by using native TLS transport Python 3.8+ rejects ssl.SSLSocket in asyncio's sock_sendall/sock_recv with TypeError. This caused the driver to fail connecting to ScyllaDB clusters requiring TLS, manifesting as 'protocol version 21 not supported' errors (0x15 = TLS Alert byte misread as protocol version). Fix by using asyncio's native TLS transport (loop.create_connection with ssl= parameter) instead of wrapping sockets with ssl.SSLContext.wrap_socket(). This preserves shard-aware port binding done during _initiate_connection(). Add _AsyncioProtocol to bridge asyncio's transport/protocol API back to Connection.process_io_buffer() for SSL data reads. Non-SSL connections continue using the existing sock_recv path. Fixes #330 --- cassandra/io/asyncioreactor.py | 199 ++++++++++++++++++++++++++++----- 1 file changed, 168 insertions(+), 31 deletions(-) diff --git a/cassandra/io/asyncioreactor.py b/cassandra/io/asyncioreactor.py index 66e1d7295c..452667c8eb 100644 --- a/cassandra/io/asyncioreactor.py +++ b/cassandra/io/asyncioreactor.py @@ -23,8 +23,8 @@ asyncio.run_coroutine_threadsafe except AttributeError: raise ImportError( - 'Cannot use asyncioreactor without access to ' - 'asyncio.run_coroutine_threadsafe (added in 3.4.6 and 3.5.1)' + "Cannot use asyncioreactor without access to " + "asyncio.run_coroutine_threadsafe (added in 3.4.6 and 3.5.1)" ) @@ -38,12 +38,12 @@ class AsyncioTimer(object): @property def end(self): - raise NotImplementedError('{} is not compatible with TimerManager and ' - 'does not implement .end()') + raise NotImplementedError( + "{} is not compatible with TimerManager and does not implement .end()" + ) def __init__(self, timeout, callback, loop): - delayed = self._call_delayed_coro(timeout=timeout, - callback=callback) + delayed = self._call_delayed_coro(timeout=timeout, callback=callback) self._handle = asyncio.run_coroutine_threadsafe(delayed, loop=loop) @staticmethod @@ -63,17 +63,61 @@ def cancel(self): def finish(self): # connection.Timer method not implemented here because we can't inspect # the Handle returned from call_later - raise NotImplementedError('{} is not compatible with TimerManager and ' - 'does not implement .finish()') + raise NotImplementedError( + "{} is not compatible with TimerManager and does not implement .finish()" + ) + + +class _AsyncioProtocol(asyncio.Protocol): + """ + Protocol adapter for asyncio SSL connections. Bridges asyncio's + transport/protocol API back to AsyncioConnection's buffer processing. + """ + + def __init__(self, connection, loop_args=None): + self._connection = connection + self.transport = None + self.write_ready = asyncio.Event(**(loop_args or {})) + self.write_ready.set() + + def connection_made(self, transport): + self.transport = transport + + def data_received(self, data): + conn = self._connection + conn._iobuf.write(data) + if conn._iobuf.tell(): + conn.process_io_buffer() + + def pause_writing(self): + self.write_ready.clear() + + def resume_writing(self): + self.write_ready.set() + + def connection_lost(self, exc): + # Unblock any paused writer so shutdown does not hang + self.write_ready.set() + conn = self._connection + if exc: + log.debug("Connection %s lost: %s", conn, exc) + conn.defunct(exc) + else: + log.debug("Connection %s closed by server", conn) + conn.close() + + def eof_received(self): + return False class AsyncioConnection(Connection): """ - An experimental implementation of :class:`.Connection` that uses the - ``asyncio`` module in the Python standard library for its event loop. + An implementation of :class:`.Connection` that uses the ``asyncio`` + module in the Python standard library for its event loop. - Note that it requires ``asyncio`` features that were only introduced in the - 3.4 line in 3.4.6, and in the 3.5 line in 3.5.1. + Supports SSL connections via asyncio's native TLS transport, which + avoids the incompatibility between ``ssl.SSLSocket`` and asyncio's + low-level socket methods (``sock_sendall``, ``sock_recv``). """ _loop = None @@ -88,26 +132,109 @@ class AsyncioConnection(Connection): def __init__(self, *args, **kwargs): Connection.__init__(self, *args, **kwargs) self._background_tasks = set() + self._transport = None + self._using_ssl = bool(self.ssl_context) self._connect_socket() self._socket.setblocking(0) loop_args = dict() if sys.version_info[0] == 3 and sys.version_info[1] < 10: - loop_args['loop'] = self._loop + loop_args["loop"] = self._loop + self._protocol = _AsyncioProtocol(self, loop_args) if self._using_ssl else None + self._ssl_ready = asyncio.Event(**loop_args) if self._using_ssl else None self._write_queue = asyncio.Queue(**loop_args) self._write_queue_lock = asyncio.Lock(**loop_args) # see initialize_reactor -- loop is running in a separate thread, so we # have to use a threadsafe call - self._read_watcher = asyncio.run_coroutine_threadsafe( - self.handle_read(), loop=self._loop - ) + if self._using_ssl: + # For SSL: set up asyncio transport/protocol, then start writer + self._read_watcher = asyncio.run_coroutine_threadsafe( + self._setup_ssl_and_run(), loop=self._loop + ) + else: + # For non-SSL: use low-level sock_sendall/sock_recv as before + self._read_watcher = asyncio.run_coroutine_threadsafe( + self.handle_read(), loop=self._loop + ) self._write_watcher = asyncio.run_coroutine_threadsafe( self.handle_write(), loop=self._loop ) self._send_options_message() + def _connect_socket(self): + """ + Override base class to skip SSL wrapping of the socket. + For SSL connections, the plain TCP socket is connected here, and TLS + is set up later via asyncio's native SSL transport in _setup_ssl_and_run(). + """ + sockerr = None + addresses = self._get_socket_addresses() + for af, socktype, proto, _, sockaddr in addresses: + try: + self._socket = self._socket_impl.socket(af, socktype, proto) + # Do NOT wrap with ssl_context here -- asyncio will handle TLS + self._socket.settimeout(self.connect_timeout) + self._initiate_connection(sockaddr) + self._socket.settimeout(None) + + local_addr = self._socket.getsockname() + log.debug("Connection %s: '%s' -> '%s'", id(self), local_addr, sockaddr) + sockerr = None + break + except socket.error as err: + if self._socket: + self._socket.close() + self._socket = None + sockerr = err + + if sockerr: + raise socket.error( + sockerr.errno, + "Tried connecting to %s. Last error: %s" + % ([a[4] for a in addresses], sockerr.strerror or sockerr), + ) + + if self.sockopts: + for args in self.sockopts: + self._socket.setsockopt(*args) + + async def _setup_ssl_and_run(self): + """ + Upgrade the plain TCP connection to TLS using asyncio's native SSL + transport, then continuously read data via the protocol callbacks. + """ + try: + ssl_context = self.ssl_context + server_hostname = None + if self.ssl_options: + server_hostname = self.ssl_options.get("server_hostname", None) + if server_hostname is None: + # asyncio's create_connection requires server_hostname when + # ssl= is set. Use endpoint address for SNI/verification when + # check_hostname is enabled; otherwise pass "" to suppress SNI. + server_hostname = ( + self.endpoint.address if ssl_context.check_hostname else "" + ) + + transport, protocol = await self._loop.create_connection( + lambda: self._protocol, + sock=self._socket, + ssl=ssl_context, + server_hostname=server_hostname, + ) + self._transport = transport + + if self._check_hostname: + self._validate_hostname() + self._ssl_ready.set() + except Exception as exc: + log.debug("SSL setup failed for %s: %s", self, exc) + self.defunct(exc) + # Unblock handle_write so it can observe the defunct state and exit + self._ssl_ready.set() + return @classmethod def initialize_reactor(cls): @@ -126,8 +253,9 @@ def initialize_reactor(cls): cls._loop = asyncio.new_event_loop() # daemonize so the loop will be shut down on interpreter # shutdown - cls._loop_thread = Thread(target=cls._loop.run_forever, - daemon=True, name="asyncio_thread") + cls._loop_thread = Thread( + target=cls._loop.run_forever, daemon=True, name="asyncio_thread" + ) cls._loop_thread.start() @classmethod @@ -142,9 +270,7 @@ def close(self): # close from the loop thread to avoid races when removing file # descriptors - asyncio.run_coroutine_threadsafe( - self._close(), loop=self._loop - ) + asyncio.run_coroutine_threadsafe(self._close(), loop=self._loop) async def _close(self): log.debug("Closing connection (%s) to %s" % (id(self), self.endpoint)) @@ -152,7 +278,10 @@ async def _close(self): self._write_watcher.cancel() if self._read_watcher: self._read_watcher.cancel() - if self._socket: + if self._transport: + self._transport.close() + self._transport = None + elif self._socket: self._loop.remove_writer(self._socket.fileno()) self._loop.remove_reader(self._socket.fileno()) self._socket.close() @@ -172,15 +301,12 @@ def push(self, data): if len(data) > buff_size: chunks = [] for i in range(0, len(data), buff_size): - chunks.append(data[i:i + buff_size]) + chunks.append(data[i : i + buff_size]) else: chunks = [data] if self._loop_thread != threading.current_thread(): - asyncio.run_coroutine_threadsafe( - self._push_msg(chunks), - loop=self._loop - ) + asyncio.run_coroutine_threadsafe(self._push_msg(chunks), loop=self._loop) else: # avoid races/hangs by just scheduling this, not using threadsafe task = self._loop.create_task(self._push_msg(chunks)) @@ -194,13 +320,25 @@ async def _push_msg(self, chunks): for chunk in chunks: self._write_queue.put_nowait(chunk) - async def handle_write(self): + # For SSL connections, wait until the TLS handshake completes + if self._ssl_ready: + await self._ssl_ready.wait() + if self.is_defunct: + return while True: try: next_msg = await self._write_queue.get() if next_msg: - await self._loop.sock_sendall(self._socket, next_msg) + if self._transport: + # SSL: use asyncio transport (handles TLS transparently) + await self._protocol.write_ready.wait() + if self.is_closed or self.is_defunct or not self._transport: + return + self._transport.write(next_msg) + else: + # Non-SSL: use low-level socket API + await self._loop.sock_sendall(self._socket, next_msg) except socket.error as err: log.debug("Exception in send for %s: %s", self, err) self.defunct(err) @@ -223,8 +361,7 @@ async def handle_read(self): await asyncio.sleep(0) continue except socket.error as err: - log.debug("Exception during socket recv for %s: %s", - self, err) + log.debug("Exception during socket recv for %s: %s", self, err) self.defunct(err) return # leave the read loop except asyncio.CancelledError: From 44bc95ad6cb66f836fc501cb045bb5fdf95643ba Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Thu, 21 May 2026 11:24:40 +0200 Subject: [PATCH 078/200] Pin GitHub Actions to commit hashes and enforce pinning - Update all action references to use full SHA commit hashes - Configure Renovate to pin digests and require 90-day minimum age - Add github-actions ecosystem to Dependabot --- .github/workflows/build-push.yml | 4 ++-- .github/workflows/call_jira_sync.yml | 2 +- .github/workflows/docs-pages.yml | 4 ++-- .github/workflows/docs-pr.yml | 4 ++-- .github/workflows/integration-tests.yml | 8 ++++---- .github/workflows/lib-build.yml | 16 ++++++++-------- .github/workflows/publish-manually.yml | 4 ++-- renovate.json | 7 +++++++ 8 files changed, 28 insertions(+), 21 deletions(-) diff --git a/.github/workflows/build-push.yml b/.github/workflows/build-push.yml index 3a3d93171a..a1a6c854c7 100644 --- a/.github/workflows/build-push.yml +++ b/.github/workflows/build-push.yml @@ -24,11 +24,11 @@ jobs: permissions: id-token: write steps: - - uses: actions/download-artifact@v8 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: dist merge-multiple: true - - uses: pypa/gh-action-pypi-publish@release/v1 + - uses: pypa/gh-action-pypi-publish@cef2210092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 with: skip-existing: true diff --git a/.github/workflows/call_jira_sync.yml b/.github/workflows/call_jira_sync.yml index 14f517df40..0855246f48 100644 --- a/.github/workflows/call_jira_sync.yml +++ b/.github/workflows/call_jira_sync.yml @@ -11,7 +11,7 @@ permissions: jobs: jira-sync: - uses: scylladb/github-automation/.github/workflows/main_pr_events_jira_sync.yml@main + uses: scylladb/github-automation/.github/workflows/main_pr_events_jira_sync.yml@83115dc2553dbf968e73271e97fc7aac16b8145a # main 2026-05-20 with: caller_action: ${{ github.event.action }} secrets: diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml index 9d14b9c4d8..a413e3317e 100644 --- a/.github/workflows/docs-pages.yml +++ b/.github/workflows/docs-pages.yml @@ -24,14 +24,14 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.event.repository.default_branch }} persist-credentials: false fetch-depth: 0 - name: Install uv - uses: astral-sh/setup-uv@v8.1.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: working-directory: docs enable-cache: true diff --git a/.github/workflows/docs-pr.yml b/.github/workflows/docs-pr.yml index f0aa64d628..1881c227ed 100644 --- a/.github/workflows/docs-pr.yml +++ b/.github/workflows/docs-pr.yml @@ -31,13 +31,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false fetch-depth: 0 - name: Install uv - uses: astral-sh/setup-uv@v8.1.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: working-directory: docs enable-cache: true diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 61261aadf8..5e76d6bbb4 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -56,10 +56,10 @@ jobs: event_loop_manager: "asyncore" steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up JDK ${{ matrix.java-version }} - uses: actions/setup-java@v5 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: java-version: ${{ matrix.java-version }} distribution: 'adopt' @@ -68,7 +68,7 @@ jobs: run: sudo apt-get install libev4 libev-dev - name: Install uv - uses: astral-sh/setup-uv@v8.1.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: python-version: ${{ matrix.python-version }} @@ -78,7 +78,7 @@ jobs: run: uv sync - name: Cache Scylla download - uses: actions/cache@v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.ccm/repository key: scylla-${{ env.SCYLLA_VERSION }}-${{ runner.os }} diff --git a/.github/workflows/lib-build.yml b/.github/workflows/lib-build.yml index 21dcc0604f..04da6cfca5 100644 --- a/.github/workflows/lib-build.yml +++ b/.github/workflows/lib-build.yml @@ -77,11 +77,11 @@ jobs: include: ${{ fromJson(needs.prepare-matrix.outputs.matrix) }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Checkout tag ${{ inputs.target_tag }} if: inputs.target_tag != '' - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ inputs.target_tag }} @@ -96,7 +96,7 @@ jobs: echo "CIBW_BEFORE_TEST_WINDOWS=(exit 0)" >> $GITHUB_ENV; - name: Install uv - uses: astral-sh/setup-uv@v8.1.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: python-version: ${{ inputs.python-version }} @@ -111,7 +111,7 @@ jobs: - name: Install Conan if: runner.os == 'Windows' - uses: turtlebrowser/get-conan@main + uses: turtlebrowser/get-conan@e41c1e039be765c0ed9d9d38cc2a287566e1d8b3 # v1.2 - name: Configure libev for Windows if: runner.os == 'Windows' @@ -147,7 +147,7 @@ jobs: run: | CIBW_BUILD="cp3*" cibuildwheel --archs aarch64 --output-dir wheelhouse - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: wheels-${{ matrix.target }}-${{ matrix.os }} path: ./wheelhouse/*.whl @@ -156,17 +156,17 @@ jobs: name: Build source distribution runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install uv - uses: astral-sh/setup-uv@v8.1.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: python-version: ${{ inputs.python-version }} - name: Build sdist run: uv build --sdist - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: source-dist path: dist/*.tar.gz diff --git a/.github/workflows/publish-manually.yml b/.github/workflows/publish-manually.yml index 2f15c6ecda..5b9298fb7f 100644 --- a/.github/workflows/publish-manually.yml +++ b/.github/workflows/publish-manually.yml @@ -58,11 +58,11 @@ jobs: permissions: id-token: write steps: - - uses: actions/download-artifact@v8 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: dist merge-multiple: true - - uses: pypa/gh-action-pypi-publish@release/v1 + - uses: pypa/gh-action-pypi-publish@cef2210092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 with: skip-existing: true diff --git a/renovate.json b/renovate.json index 5db72dd6a9..d85ac38c01 100644 --- a/renovate.json +++ b/renovate.json @@ -2,5 +2,12 @@ "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ "config:recommended" + ], + "packageRules": [ + { + "matchManagers": ["github-actions"], + "pinDigests": true, + "minimumReleaseAge": "90 days" + } ] } From 037118e77ffaf82953bebc035f27ea6a533235a1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 09:18:40 +0000 Subject: [PATCH 079/200] chore(deps): update turtlebrowser/get-conan digest to c171f29 --- .github/workflows/lib-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lib-build.yml b/.github/workflows/lib-build.yml index 04da6cfca5..f6959ddfec 100644 --- a/.github/workflows/lib-build.yml +++ b/.github/workflows/lib-build.yml @@ -111,7 +111,7 @@ jobs: - name: Install Conan if: runner.os == 'Windows' - uses: turtlebrowser/get-conan@e41c1e039be765c0ed9d9d38cc2a287566e1d8b3 # v1.2 + uses: turtlebrowser/get-conan@c171f295f3f507360ee018736a6608731aa2109d # v1.2 - name: Configure libev for Windows if: runner.os == 'Windows' From c08913bc947ea1a68374965771ea48cc48e3d9f7 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Tue, 2 Jun 2026 08:31:43 +0200 Subject: [PATCH 080/200] ci: update scylladb/github-automation to latest main hash --- .github/workflows/call_jira_sync.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/call_jira_sync.yml b/.github/workflows/call_jira_sync.yml index 0855246f48..7397f10cdc 100644 --- a/.github/workflows/call_jira_sync.yml +++ b/.github/workflows/call_jira_sync.yml @@ -11,7 +11,7 @@ permissions: jobs: jira-sync: - uses: scylladb/github-automation/.github/workflows/main_pr_events_jira_sync.yml@83115dc2553dbf968e73271e97fc7aac16b8145a # main 2026-05-20 + uses: scylladb/github-automation/.github/workflows/main_pr_events_jira_sync.yml@47138e9130250ee1a35166cff7dd0e94c8897196 # main 2026-06-01 with: caller_action: ${{ github.event.action }} secrets: From 28ddc074b53d9f4164e64fcd4b87fc307daada51 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Wed, 10 Jun 2026 09:36:12 +0200 Subject: [PATCH 081/200] tests: reduce nested type/UDT depth to 12 for new CQL nesting limit Scylla now caps the nesting depth of CQL expressions in the parser, rejecting deeply nested literals with: SyntaxException code=2000 'expression nested too deeply' Cap the deepest case at 12, the maximum depth the server now allows. Caused by scylladb/scylladb commit e35c388 ('cql3: limit nesting depth of function calls and CASTs in CQL parser') https://github.com/scylladb/scylladb/commit/c27e32299dcd7579fd9d80f5d9c02421b493c40a. --- tests/integration/standard/test_types.py | 8 +++++--- tests/integration/standard/test_udts.py | 6 +++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/integration/standard/test_types.py b/tests/integration/standard/test_types.py index 559a6b3da0..6bf25ce163 100644 --- a/tests/integration/standard/test_types.py +++ b/tests/integration/standard/test_types.py @@ -663,18 +663,20 @@ def test_can_insert_nested_tuples(self): s.encoder.mapping[tuple] = s.encoder.cql_encode_tuple # create a table with multiple sizes of nested tuples + # Note: Scylla limits CQL expression nesting depth to 12, so the + # deepest tuple tested here is 12 levels deep. s.execute("CREATE TABLE nested_tuples (" "k int PRIMARY KEY, " "v_1 frozen<%s>," "v_2 frozen<%s>," "v_3 frozen<%s>," - "v_32 frozen<%s>" + "v_12 frozen<%s>" ")" % (self.nested_tuples_schema_helper(1), self.nested_tuples_schema_helper(2), self.nested_tuples_schema_helper(3), - self.nested_tuples_schema_helper(32))) + self.nested_tuples_schema_helper(12))) - for i in (1, 2, 3, 32): + for i in (1, 2, 3, 12): # create tuple created_tuple = self.nested_tuples_creator_helper(i) diff --git a/tests/integration/standard/test_udts.py b/tests/integration/standard/test_udts.py index 11888adda4..7533601757 100644 --- a/tests/integration/standard/test_udts.py +++ b/tests/integration/standard/test_udts.py @@ -389,7 +389,7 @@ def test_can_insert_nested_registered_udts(self): with self._cluster_default_dict_factory() as c: s = c.connect(self.keyspace_name, wait_for_all_pools=True) - max_nesting_depth = 16 + max_nesting_depth = 12 # create the schema self.nested_udt_schema_helper(s, max_nesting_depth) @@ -417,7 +417,7 @@ def test_can_insert_nested_unregistered_udts(self): with self._cluster_default_dict_factory() as c: s = c.connect(self.keyspace_name, wait_for_all_pools=True) - max_nesting_depth = 16 + max_nesting_depth = 12 # create the schema self.nested_udt_schema_helper(s, max_nesting_depth) @@ -454,7 +454,7 @@ def test_can_insert_nested_registered_udts_with_different_namedtuples(self): with self._cluster_default_dict_factory() as c: s = c.connect(self.keyspace_name, wait_for_all_pools=True) - max_nesting_depth = 16 + max_nesting_depth = 12 # create the schema self.nested_udt_schema_helper(s, max_nesting_depth) From bf7966fcad2f98d5a4d0574f6f4a92614e2f7470 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Wed, 10 Jun 2026 13:32:29 +0200 Subject: [PATCH 082/200] Reduce nesting depth in tests to respect server CQL limit Scylla now limits CQL expression nesting depth to 12 (CVE-2026-31948, scylladb commit e35c388), rejecting deeper literals with the error "expression nested too deeply". The limit counts every recursive `term`, including the innermost scalar value: - nested tuple literals max out at 11 levels deep - nested UDT literals max out at 10 levels deep (a UDT literal {value: ...} adds two term levels per nesting) Adjust test_can_insert_nested_tuples to depth 11 and the nested UDT tests to depth 10. --- tests/integration/standard/test_types.py | 12 +++++++----- tests/integration/standard/test_udts.py | 14 ++++++++++++-- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/tests/integration/standard/test_types.py b/tests/integration/standard/test_types.py index 6bf25ce163..d742f84ffb 100644 --- a/tests/integration/standard/test_types.py +++ b/tests/integration/standard/test_types.py @@ -663,20 +663,22 @@ def test_can_insert_nested_tuples(self): s.encoder.mapping[tuple] = s.encoder.cql_encode_tuple # create a table with multiple sizes of nested tuples - # Note: Scylla limits CQL expression nesting depth to 12, so the - # deepest tuple tested here is 12 levels deep. + # Note: Scylla limits CQL expression nesting depth to 12 (every + # recursive `term` counts, including the innermost scalar value), so a + # nested tuple literal can be at most 11 levels deep before the server + # rejects it with "expression nested too deeply". s.execute("CREATE TABLE nested_tuples (" "k int PRIMARY KEY, " "v_1 frozen<%s>," "v_2 frozen<%s>," "v_3 frozen<%s>," - "v_12 frozen<%s>" + "v_11 frozen<%s>" ")" % (self.nested_tuples_schema_helper(1), self.nested_tuples_schema_helper(2), self.nested_tuples_schema_helper(3), - self.nested_tuples_schema_helper(12))) + self.nested_tuples_schema_helper(11))) - for i in (1, 2, 3, 12): + for i in (1, 2, 3, 11): # create tuple created_tuple = self.nested_tuples_creator_helper(i) diff --git a/tests/integration/standard/test_udts.py b/tests/integration/standard/test_udts.py index 7533601757..520df49413 100644 --- a/tests/integration/standard/test_udts.py +++ b/tests/integration/standard/test_udts.py @@ -389,7 +389,12 @@ def test_can_insert_nested_registered_udts(self): with self._cluster_default_dict_factory() as c: s = c.connect(self.keyspace_name, wait_for_all_pools=True) - max_nesting_depth = 12 + # Scylla caps CQL expression nesting depth at 12 (every recursive + # `term` counts). A UDT literal `{value: ...}` adds two term levels + # per nesting, so a UDT literal inserted via a simple statement can + # be at most 10 levels deep before the server rejects it with + # "expression nested too deeply". + max_nesting_depth = 10 # create the schema self.nested_udt_schema_helper(s, max_nesting_depth) @@ -454,7 +459,12 @@ def test_can_insert_nested_registered_udts_with_different_namedtuples(self): with self._cluster_default_dict_factory() as c: s = c.connect(self.keyspace_name, wait_for_all_pools=True) - max_nesting_depth = 12 + # Scylla caps CQL expression nesting depth at 12 (every recursive + # `term` counts). A UDT literal `{value: ...}` adds two term levels + # per nesting, so a UDT literal inserted via a simple statement can + # be at most 10 levels deep before the server rejects it with + # "expression nested too deeply". + max_nesting_depth = 10 # create the schema self.nested_udt_schema_helper(s, max_nesting_depth) From 2e0ae9475b152d00e42cbdc01e63aa6ab39657a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karol=20Bary=C5=82a?= Date: Fri, 12 Jun 2026 17:47:55 +0200 Subject: [PATCH 083/200] libev reactor: Defer socket close until after watchers stop `close` can be called from anywhere, not only reactor threads. If such `close` call closes socket during `handle_write` / `handle_read`, then those functions may try to operate on closed socket. Solution implemented in this commit: defer socket closing until both watchers are stopped. --- cassandra/io/libevreactor.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cassandra/io/libevreactor.py b/cassandra/io/libevreactor.py index 3da809931f..f3b0541834 100644 --- a/cassandra/io/libevreactor.py +++ b/cassandra/io/libevreactor.py @@ -124,6 +124,7 @@ def _cleanup(self): for watcher in (conn._write_watcher, conn._read_watcher): if watcher: watcher.stop() + conn._socket.close() self.notify() # wake the timer watcher @@ -221,6 +222,8 @@ def _loop_will_run(self, prepare): conn._read_watcher.stop() # clear reference cycles from IO callback del conn._read_watcher + conn._socket.close() + log.debug("Closed socket to %s", conn.endpoint) changed = True @@ -233,7 +236,7 @@ def _loop_will_run(self, prepare): def _atexit_cleanup(): """Cleanup function called by atexit that uses the current _global_loop value. - + This wrapper ensures that cleanup receives the actual LibevLoop instance instead of None, which was the value of _global_loop when the module was imported. @@ -308,8 +311,6 @@ def close(self): log.debug("Closing connection (%s) to %s", id(self), self.endpoint) _global_loop.connection_destroyed(self) - self._socket.close() - log.debug("Closed socket to %s", self.endpoint) # don't leave in-progress operations hanging if not self.is_defunct: From 24788e376971c162e0416a1357ceb127d161b103 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karol=20Bary=C5=82a?= Date: Fri, 12 Jun 2026 17:50:24 +0200 Subject: [PATCH 084/200] libev reactor: Return from watchers for closed connection Previous commit defered socket close until watchers are stopped, but there is one more case worth considering. If during one libev loop iteration socket gets ready for both read and write, then both watchers will be called. If one decides to close the connection, the other one will still get called anyway. This shouldn't cause EBADF, because socket won't be closed yet, but I see no reason to perform unnecessary work. --- cassandra/io/libevreactor.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cassandra/io/libevreactor.py b/cassandra/io/libevreactor.py index f3b0541834..6cceb6c6bc 100644 --- a/cassandra/io/libevreactor.py +++ b/cassandra/io/libevreactor.py @@ -321,6 +321,8 @@ def close(self): self.connected_event.set() def handle_write(self, watcher, revents, errno=None): + if self.is_closed: + return if revents & libev.EV_ERROR: if errno: exc = IOError(errno, os.strerror(errno)) @@ -362,6 +364,8 @@ def handle_write(self, watcher, revents, errno=None): return def handle_read(self, watcher, revents, errno=None): + if self.is_closed: + return if revents & libev.EV_ERROR: if errno: exc = IOError(errno, os.strerror(errno)) From 29d01e232f5e5e50fcf570e717d9f3f77fb68d16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karol=20Bary=C5=82a?= Date: Fri, 12 Jun 2026 17:54:37 +0200 Subject: [PATCH 085/200] factory: raise on closed connections When connection is closed by the server, but there is no other error, it will be close (is_cloes == True) without setting `last_error`. This is true for all reactors apart from Twisted as far as I can tell. If we try to use such connection, we'll quickly discover that its broken, but we can slightly optimize this process by raising directly from factory(). --- cassandra/connection.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cassandra/connection.py b/cassandra/connection.py index f07160e385..eae018649b 100644 --- a/cassandra/connection.py +++ b/cassandra/connection.py @@ -986,6 +986,8 @@ def factory(cls, endpoint, timeout, host_conn = None, *args, **kwargs): conn.close() raise OperationTimedOut("Timed out creating connection (%s seconds)" % timeout, timeout=timeout) + elif conn.is_closed: + raise ConnectionShutdown("Connection to %s was closed by server" % conn.endpoint) else: return conn From f5dea1defd6b8f9d719450073ddacf0d75656734 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karol=20Bary=C5=82a?= Date: Mon, 15 Jun 2026 12:54:55 +0200 Subject: [PATCH 086/200] CI: Use correct hash for pypa/gh-action-pypi-publish --- .github/workflows/build-push.yml | 2 +- .github/workflows/publish-manually.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-push.yml b/.github/workflows/build-push.yml index a1a6c854c7..60f0983fd4 100644 --- a/.github/workflows/build-push.yml +++ b/.github/workflows/build-push.yml @@ -29,6 +29,6 @@ jobs: path: dist merge-multiple: true - - uses: pypa/gh-action-pypi-publish@cef2210092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 with: skip-existing: true diff --git a/.github/workflows/publish-manually.yml b/.github/workflows/publish-manually.yml index 5b9298fb7f..e38de5b0c4 100644 --- a/.github/workflows/publish-manually.yml +++ b/.github/workflows/publish-manually.yml @@ -63,6 +63,6 @@ jobs: path: dist merge-multiple: true - - uses: pypa/gh-action-pypi-publish@cef2210092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 with: skip-existing: true From 763af091452e4f6ba01a56a632ee232aeac9dab5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karol=20Bary=C5=82a?= Date: Mon, 15 Jun 2026 11:39:53 +0200 Subject: [PATCH 087/200] Release 3.29.11 --- CHANGELOG.rst | 19 +++++++++++++++++++ cassandra/__init__.py | 2 +- docs/conf.py | 8 ++++---- docs/installation.rst | 4 ++-- 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 39a8aca069..72ad29fae7 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,22 @@ +3.29.11 +======= +Jun 15, 2026 + +Features +-------- +* asyncio backend now supports TLS + +Bug Fixes +--------- +* Race conditions in libev backend resulting in EBADF error have been fixed + +Testing / CI +------------ +* Integration tests now use ``NetworkTopologyStrategy`` instead of ``SimpleStrategy`` +* All actions used in CI are now hash-pinned to decrease risk of supply-chain attacks +* Various fixes to make CI tests work with various versions of Scylla - mostly related to tablets and LWT +* Bumped Scylla version used in CI to 2026.1 + 3.29.10 ======= May 10, 2026 diff --git a/cassandra/__init__.py b/cassandra/__init__.py index 1286f20e9b..cb3703d40a 100644 --- a/cassandra/__init__.py +++ b/cassandra/__init__.py @@ -23,7 +23,7 @@ def emit(self, record): logging.getLogger('cassandra').addHandler(NullHandler()) -__version_info__ = (3, 29, 10) +__version_info__ = (3, 29, 11) __version__ = '.'.join(map(str, __version_info__)) diff --git a/docs/conf.py b/docs/conf.py index 34ef31ccae..b43d2ca948 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -29,17 +29,17 @@ '3.29.6-scylla', '3.29.7-scylla', '3.29.8-scylla', - '3.29.10-scylla', + '3.29.11-scylla', ] BRANCHES = ['master'] # Set the latest version. -LATEST_VERSION = '3.29.10-scylla' +LATEST_VERSION = '3.29.11-scylla' # Set which versions are not released yet. UNSTABLE_VERSIONS = ['master'] # Set which versions are deprecated DEPRECATED_VERSIONS = ['3.21.0-scylla', '3.22.3-scylla', '3.24.8-scylla', '3.25.4-scylla', '3.25.11-scylla', '3.26.9-scylla', '3.28.1-scylla', '3.29.1-scylla'] -# -- General configuration +# -- General configuration # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. @@ -157,7 +157,7 @@ # Output file base name for HTML help builder. htmlhelp_basename = 'CassandraDriverdoc' -# URL which points to the root of the HTML documentation. +# URL which points to the root of the HTML documentation. html_baseurl = 'https://python-driver.docs.scylladb.com' # Dictionary of values to pass into the template engine’s context for all pages diff --git a/docs/installation.rst b/docs/installation.rst index 6a4b38ea80..b3a79f2940 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -26,7 +26,7 @@ To check if the installation was successful, you can run:: python -c 'import cassandra; print(cassandra.__version__)' -It should print something like "3.29.10". +It should print something like "3.29.11". (*Optional*) Compression Support -------------------------------- @@ -190,7 +190,7 @@ through `Homebrew `_. For example, on Mac OS X:: $ brew install libev -The libev extension can now be built for Windows as of Python driver version 3.29.10. You can +The libev extension can now be built for Windows as of Python driver version 3.29.11. You can install libev using any Windows package manager. For example, to install using `vcpkg `_: $ vcpkg install libev From c1bfd5467a2ceb16166f3798bc09a944e1d9dc3f Mon Sep 17 00:00:00 2001 From: David Garcia Date: Thu, 16 Apr 2026 14:57:40 +0100 Subject: [PATCH 088/200] docs: update theme 1.9.2 --- docs/pyproject.toml | 2 +- docs/uv.lock | 460 +++++++++++++++++++++++--------------------- 2 files changed, 238 insertions(+), 224 deletions(-) diff --git a/docs/pyproject.toml b/docs/pyproject.toml index 762a4f2e49..7aa0e2844b 100644 --- a/docs/pyproject.toml +++ b/docs/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ "redirects_cli~=0.1.3", "sphinx-autobuild>=2025.0.0,<2026.0.0", "sphinx-sitemap>=2.8.0,<3.0.0", - "sphinx-scylladb-theme>=1.9.1", + "sphinx-scylladb-theme>=1.9.2", "sphinx-multiversion-scylla>=0.3.2,<1.0.0", "sphinx>=9.0", "six>=1.9", diff --git a/docs/uv.lock b/docs/uv.lock index 515e37abba..19962f649f 100644 --- a/docs/uv.lock +++ b/docs/uv.lock @@ -4,11 +4,11 @@ requires-python = "==3.13.*" [[package]] name = "aenum" -version = "3.1.16" +version = "3.1.17" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/09/7a/61ed58e8be9e30c3fe518899cc78c284896d246d51381bab59b5db11e1f3/aenum-3.1.16.tar.gz", hash = "sha256:bfaf9589bdb418ee3a986d85750c7318d9d2839c1b1a1d6fe8fc53ec201cf140", size = 137693, upload-time = "2026-01-12T22:34:38.819Z" } +sdist = { url = "https://files.pythonhosted.org/packages/07/e9/8b283567c1fef7c24d1f390b37daede8b61593d8cdaffb8e95d571699e83/aenum-3.1.17.tar.gz", hash = "sha256:a969a4516b194895de72c875ece355f17c0d272146f7fda346ef74f93cf4d5ba", size = 137648, upload-time = "2026-03-20T20:43:29.846Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/52/6ad8f63ec8da1bf40f96996d25d5b650fdd38f5975f8c813732c47388f18/aenum-3.1.16-py3-none-any.whl", hash = "sha256:9035092855a98e41b66e3d0998bd7b96280e85ceb3a04cc035636138a1943eaf", size = 165627, upload-time = "2025-04-25T03:17:58.89Z" }, + { url = "https://files.pythonhosted.org/packages/48/8d/1fe30c6fd8999b9d462547c4a1bb6690bda24af38f2913c4bec7decb81f2/aenum-3.1.17-py3-none-any.whl", hash = "sha256:8b883a37a04e74cc838ac442bdd28c266eae5bbf13e1342c7ef123ed25230139", size = 165560, upload-time = "2026-03-20T20:43:27.681Z" }, ] [[package]] @@ -22,7 +22,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.3" +version = "3.13.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -33,25 +33,25 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } +sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" }, - { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" }, - { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" }, - { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" }, - { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" }, - { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" }, - { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" }, - { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" }, - { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" }, - { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" }, - { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" }, - { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" }, - { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, - { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, - { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, + { url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" }, + { url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" }, + { url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" }, + { url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" }, + { url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" }, + { url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" }, + { url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" }, + { url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" }, + { url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" }, + { url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" }, + { url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" }, ] [[package]] @@ -75,16 +75,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, ] +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + [[package]] name = "anyio" -version = "4.12.0" +version = "4.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" }, + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] [[package]] @@ -98,29 +107,29 @@ wheels = [ [[package]] name = "attrs" -version = "25.4.0" +version = "26.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] [[package]] name = "babel" -version = "2.17.0" +version = "2.18.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] [[package]] name = "beartype" -version = "0.22.8" +version = "0.22.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/1d/794ae2acaa67c8b216d91d5919da2606c2bb14086849ffde7f5555f3a3a5/beartype-0.22.8.tar.gz", hash = "sha256:b19b21c9359722ee3f7cc433f063b3e13997b27ae8226551ea5062e621f61165", size = 1602262, upload-time = "2025-12-03T05:11:10.766Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/2a/fbcbf5a025d3e71ddafad7efd43e34ec4362f4d523c3c471b457148fb211/beartype-0.22.8-py3-none-any.whl", hash = "sha256:b832882d04e41a4097bab9f63e6992bc6de58c414ee84cba9b45b67314f5ab2e", size = 1331895, upload-time = "2025-12-03T05:11:08.373Z" }, + { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, ] [[package]] @@ -138,11 +147,11 @@ wheels = [ [[package]] name = "certifi" -version = "2025.11.12" +version = "2026.2.25" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] [[package]] @@ -161,39 +170,39 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, - { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, - { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, - { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, - { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, - { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, - { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, - { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, - { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, - { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, - { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, - { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, - { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, - { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] [[package]] name = "click" -version = "8.3.1" +version = "8.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, ] [[package]] @@ -216,24 +225,24 @@ wheels = [ [[package]] name = "docutils" -version = "0.21.2" +version = "0.22.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, ] [[package]] name = "eventlet" -version = "0.40.4" +version = "0.41.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "dnspython" }, { name = "greenlet" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/d8/f72d8583db7c559445e0e9500a9b9787332370c16980802204a403634585/eventlet-0.40.4.tar.gz", hash = "sha256:69bef712b1be18b4930df6f0c495d2a882bf7b63aa111e7b6eeff461cfcaf26f", size = 565920, upload-time = "2025-11-26T13:57:31.126Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/90/32772ae7c9897554c56b9367b67478a3dc89c70d9b4d12e241746f6fdae3/eventlet-0.41.0.tar.gz", hash = "sha256:35df85f0ccd3e73effb6fd9f1ceae46b500b966c7da1817289c323a307bd397b", size = 565911, upload-time = "2026-04-02T07:33:23.605Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/6d/8e1fa901f6a8307f90e7bd932064e27a0062a4a7a16af38966a9c3293c52/eventlet-0.40.4-py3-none-any.whl", hash = "sha256:6326c6d0bf55810bece151f7a5750207c610f389ba110ffd1541ed6e5215485b", size = 364588, upload-time = "2025-11-26T13:57:29.09Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1c/febe9acf1b4f0d67603b231c28d6d17d647d68c90c1963fecdeb64046d6d/eventlet-0.41.0-py3-none-any.whl", hash = "sha256:bc22396093cb4119ff7007776be6a5348a613ccd42eeb0f9519853a6efcbcabe", size = 364574, upload-time = "2026-04-02T07:33:21.756Z" }, ] [[package]] @@ -301,18 +310,20 @@ wheels = [ [[package]] name = "greenlet" -version = "3.3.0" +version = "3.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/e5/40dbda2736893e3e53d25838e0f19a2b417dfc122b9989c91918db30b5d3/greenlet-3.3.0.tar.gz", hash = "sha256:a82bb225a4e9e4d653dd2fb7b8b2d36e4fb25bc0165422a11e48b88e9e6f78fb", size = 190651, upload-time = "2025-12-04T14:49:44.05Z" } +sdist = { url = "https://files.pythonhosted.org/packages/86/94/a5935717b307d7c71fe877b52b884c6af707d2d2090db118a03fbd799369/greenlet-3.4.0.tar.gz", hash = "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff", size = 195913, upload-time = "2026-04-08T17:08:00.863Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" }, - { url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" }, - { url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" }, - { url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" }, - { url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" }, - { url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" }, - { url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" }, - { url = "https://files.pythonhosted.org/packages/7e/71/ba21c3fb8c5dce83b8c01f458a42e99ffdb1963aeec08fff5a18588d8fd7/greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38", size = 301833, upload-time = "2025-12-04T14:32:23.929Z" }, + { url = "https://files.pythonhosted.org/packages/7a/75/7e9cd1126a1e1f0cd67b0eda02e5221b28488d352684704a78ed505bd719/greenlet-3.4.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:43748988b097f9c6f09364f260741aa73c80747f63389824435c7a50bfdfd5c1", size = 285856, upload-time = "2026-04-08T15:52:45.82Z" }, + { url = "https://files.pythonhosted.org/packages/9d/c4/3e2df392e5cb199527c4d9dbcaa75c14edcc394b45040f0189f649631e3c/greenlet-3.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5566e4e2cd7a880e8c27618e3eab20f3494452d12fd5129edef7b2f7aa9a36d1", size = 610208, upload-time = "2026-04-08T16:24:39.674Z" }, + { url = "https://files.pythonhosted.org/packages/da/af/750cdfda1d1bd30a6c28080245be8d0346e669a98fdbae7f4102aa95fff3/greenlet-3.4.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1054c5a3c78e2ab599d452f23f7adafef55062a783a8e241d24f3b633ba6ff82", size = 621269, upload-time = "2026-04-08T16:30:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/e0/93/c8c508d68ba93232784bbc1b5474d92371f2897dfc6bc281b419f2e0d492/greenlet-3.4.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98eedd1803353daf1cd9ef23eef23eda5a4d22f99b1f998d273a8b78b70dd47f", size = 628455, upload-time = "2026-04-08T16:40:40.698Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/0cbc693622cd54ebe25207efbb3a0eb07c2639cb8594f6e3aaaa0bb077a8/greenlet-3.4.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f82cb6cddc27dd81c96b1506f4aa7def15070c3b2a67d4e46fd19016aacce6cf", size = 617549, upload-time = "2026-04-08T15:56:34.893Z" }, + { url = "https://files.pythonhosted.org/packages/7f/46/cfaaa0ade435a60550fd83d07dfd5c41f873a01da17ede5c4cade0b9bab8/greenlet-3.4.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:b7857e2202aae67bc5725e0c1f6403c20a8ff46094ece015e7d474f5f7020b55", size = 426238, upload-time = "2026-04-08T16:43:06.865Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c0/8966767de01343c1ff47e8b855dc78e7d1a8ed2b7b9c83576a57e289f81d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:227a46251ecba4ff46ae742bc5ce95c91d5aceb4b02f885487aff269c127a729", size = 1575310, upload-time = "2026-04-08T16:26:21.671Z" }, + { url = "https://files.pythonhosted.org/packages/b8/38/bcdc71ba05e9a5fda87f63ffc2abcd1f15693b659346df994a48c968003d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5b99e87be7eba788dd5b75ba1cde5639edffdec5f91fe0d734a249535ec3408c", size = 1640435, upload-time = "2026-04-08T15:57:32.572Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c2/19b664b7173b9e4ef5f77e8cef9f14c20ec7fce7920dc1ccd7afd955d093/greenlet-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:849f8bc17acd6295fcb5de8e46d55cc0e52381c56eaf50a2afd258e97bc65940", size = 238760, upload-time = "2026-04-08T17:04:03.878Z" }, + { url = "https://files.pythonhosted.org/packages/9b/96/795619651d39c7fbd809a522f881aa6f0ead504cc8201c3a5b789dfaef99/greenlet-3.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:9390ad88b652b1903814eaabd629ca184db15e0eeb6fe8a390bbf8b9106ae15a", size = 235498, upload-time = "2026-04-08T17:05:00.584Z" }, ] [[package]] @@ -366,11 +377,11 @@ wheels = [ [[package]] name = "imagesize" -version = "1.4.1" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a7/84/62473fb57d61e31fef6e36d64a179c8781605429fd927b5dd608c997be31/imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a", size = 1280026, upload-time = "2022-07-01T12:21:05.687Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769, upload-time = "2022-07-01T12:21:02.467Z" }, + { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, ] [[package]] @@ -459,47 +470,47 @@ wheels = [ [[package]] name = "multidict" -version = "6.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/80/1e/5492c365f222f907de1039b91f922b93fa4f764c713ee858d235495d8f50/multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5", size = 101834, upload-time = "2025-10-06T14:52:30.657Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/86/33272a544eeb36d66e4d9a920602d1a2f57d4ebea4ef3cdfe5a912574c95/multidict-6.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bee7c0588aa0076ce77c0ea5d19a68d76ad81fcd9fe8501003b9a24f9d4000f6", size = 76135, upload-time = "2025-10-06T14:49:54.26Z" }, - { url = "https://files.pythonhosted.org/packages/91/1c/eb97db117a1ebe46d457a3d235a7b9d2e6dcab174f42d1b67663dd9e5371/multidict-6.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7ef6b61cad77091056ce0e7ce69814ef72afacb150b7ac6a3e9470def2198159", size = 45117, upload-time = "2025-10-06T14:49:55.82Z" }, - { url = "https://files.pythonhosted.org/packages/f1/d8/6c3442322e41fb1dd4de8bd67bfd11cd72352ac131f6368315617de752f1/multidict-6.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c0359b1ec12b1d6849c59f9d319610b7f20ef990a6d454ab151aa0e3b9f78ca", size = 43472, upload-time = "2025-10-06T14:49:57.048Z" }, - { url = "https://files.pythonhosted.org/packages/75/3f/e2639e80325af0b6c6febdf8e57cc07043ff15f57fa1ef808f4ccb5ac4cd/multidict-6.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cd240939f71c64bd658f186330603aac1a9a81bf6273f523fca63673cb7378a8", size = 249342, upload-time = "2025-10-06T14:49:58.368Z" }, - { url = "https://files.pythonhosted.org/packages/5d/cc/84e0585f805cbeaa9cbdaa95f9a3d6aed745b9d25700623ac89a6ecff400/multidict-6.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60a4d75718a5efa473ebd5ab685786ba0c67b8381f781d1be14da49f1a2dc60", size = 257082, upload-time = "2025-10-06T14:49:59.89Z" }, - { url = "https://files.pythonhosted.org/packages/b0/9c/ac851c107c92289acbbf5cfb485694084690c1b17e555f44952c26ddc5bd/multidict-6.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53a42d364f323275126aff81fb67c5ca1b7a04fda0546245730a55c8c5f24bc4", size = 240704, upload-time = "2025-10-06T14:50:01.485Z" }, - { url = "https://files.pythonhosted.org/packages/50/cc/5f93e99427248c09da95b62d64b25748a5f5c98c7c2ab09825a1d6af0e15/multidict-6.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3b29b980d0ddbecb736735ee5bef69bb2ddca56eff603c86f3f29a1128299b4f", size = 266355, upload-time = "2025-10-06T14:50:02.955Z" }, - { url = "https://files.pythonhosted.org/packages/ec/0c/2ec1d883ceb79c6f7f6d7ad90c919c898f5d1c6ea96d322751420211e072/multidict-6.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8a93b1c0ed2d04b97a5e9336fd2d33371b9a6e29ab7dd6503d63407c20ffbaf", size = 267259, upload-time = "2025-10-06T14:50:04.446Z" }, - { url = "https://files.pythonhosted.org/packages/c6/2d/f0b184fa88d6630aa267680bdb8623fb69cb0d024b8c6f0d23f9a0f406d3/multidict-6.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff96e8815eecacc6645da76c413eb3b3d34cfca256c70b16b286a687d013c32", size = 254903, upload-time = "2025-10-06T14:50:05.98Z" }, - { url = "https://files.pythonhosted.org/packages/06/c9/11ea263ad0df7dfabcad404feb3c0dd40b131bc7f232d5537f2fb1356951/multidict-6.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7516c579652f6a6be0e266aec0acd0db80829ca305c3d771ed898538804c2036", size = 252365, upload-time = "2025-10-06T14:50:07.511Z" }, - { url = "https://files.pythonhosted.org/packages/41/88/d714b86ee2c17d6e09850c70c9d310abac3d808ab49dfa16b43aba9d53fd/multidict-6.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:040f393368e63fb0f3330e70c26bfd336656bed925e5cbe17c9da839a6ab13ec", size = 250062, upload-time = "2025-10-06T14:50:09.074Z" }, - { url = "https://files.pythonhosted.org/packages/15/fe/ad407bb9e818c2b31383f6131ca19ea7e35ce93cf1310fce69f12e89de75/multidict-6.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b3bc26a951007b1057a1c543af845f1c7e3e71cc240ed1ace7bf4484aa99196e", size = 249683, upload-time = "2025-10-06T14:50:10.714Z" }, - { url = "https://files.pythonhosted.org/packages/8c/a4/a89abdb0229e533fb925e7c6e5c40201c2873efebc9abaf14046a4536ee6/multidict-6.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7b022717c748dd1992a83e219587aabe45980d88969f01b316e78683e6285f64", size = 261254, upload-time = "2025-10-06T14:50:12.28Z" }, - { url = "https://files.pythonhosted.org/packages/8d/aa/0e2b27bd88b40a4fb8dc53dd74eecac70edaa4c1dd0707eb2164da3675b3/multidict-6.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9600082733859f00d79dee64effc7aef1beb26adb297416a4ad2116fd61374bd", size = 257967, upload-time = "2025-10-06T14:50:14.16Z" }, - { url = "https://files.pythonhosted.org/packages/d0/8e/0c67b7120d5d5f6d874ed85a085f9dc770a7f9d8813e80f44a9fec820bb7/multidict-6.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94218fcec4d72bc61df51c198d098ce2b378e0ccbac41ddbed5ef44092913288", size = 250085, upload-time = "2025-10-06T14:50:15.639Z" }, - { url = "https://files.pythonhosted.org/packages/ba/55/b73e1d624ea4b8fd4dd07a3bb70f6e4c7c6c5d9d640a41c6ffe5cdbd2a55/multidict-6.7.0-cp313-cp313-win32.whl", hash = "sha256:a37bd74c3fa9d00be2d7b8eca074dc56bd8077ddd2917a839bd989612671ed17", size = 41713, upload-time = "2025-10-06T14:50:17.066Z" }, - { url = "https://files.pythonhosted.org/packages/32/31/75c59e7d3b4205075b4c183fa4ca398a2daf2303ddf616b04ae6ef55cffe/multidict-6.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:30d193c6cc6d559db42b6bcec8a5d395d34d60c9877a0b71ecd7c204fcf15390", size = 45915, upload-time = "2025-10-06T14:50:18.264Z" }, - { url = "https://files.pythonhosted.org/packages/31/2a/8987831e811f1184c22bc2e45844934385363ee61c0a2dcfa8f71b87e608/multidict-6.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:ea3334cabe4d41b7ccd01e4d349828678794edbc2d3ae97fc162a3312095092e", size = 43077, upload-time = "2025-10-06T14:50:19.853Z" }, - { url = "https://files.pythonhosted.org/packages/e8/68/7b3a5170a382a340147337b300b9eb25a9ddb573bcdfff19c0fa3f31ffba/multidict-6.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ad9ce259f50abd98a1ca0aa6e490b58c316a0fce0617f609723e40804add2c00", size = 83114, upload-time = "2025-10-06T14:50:21.223Z" }, - { url = "https://files.pythonhosted.org/packages/55/5c/3fa2d07c84df4e302060f555bbf539310980362236ad49f50eeb0a1c1eb9/multidict-6.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07f5594ac6d084cbb5de2df218d78baf55ef150b91f0ff8a21cc7a2e3a5a58eb", size = 48442, upload-time = "2025-10-06T14:50:22.871Z" }, - { url = "https://files.pythonhosted.org/packages/fc/56/67212d33239797f9bd91962bb899d72bb0f4c35a8652dcdb8ed049bef878/multidict-6.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0591b48acf279821a579282444814a2d8d0af624ae0bc600aa4d1b920b6e924b", size = 46885, upload-time = "2025-10-06T14:50:24.258Z" }, - { url = "https://files.pythonhosted.org/packages/46/d1/908f896224290350721597a61a69cd19b89ad8ee0ae1f38b3f5cd12ea2ac/multidict-6.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:749a72584761531d2b9467cfbdfd29487ee21124c304c4b6cb760d8777b27f9c", size = 242588, upload-time = "2025-10-06T14:50:25.716Z" }, - { url = "https://files.pythonhosted.org/packages/ab/67/8604288bbd68680eee0ab568fdcb56171d8b23a01bcd5cb0c8fedf6e5d99/multidict-6.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b4c3d199f953acd5b446bf7c0de1fe25d94e09e79086f8dc2f48a11a129cdf1", size = 249966, upload-time = "2025-10-06T14:50:28.192Z" }, - { url = "https://files.pythonhosted.org/packages/20/33/9228d76339f1ba51e3efef7da3ebd91964d3006217aae13211653193c3ff/multidict-6.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9fb0211dfc3b51efea2f349ec92c114d7754dd62c01f81c3e32b765b70c45c9b", size = 228618, upload-time = "2025-10-06T14:50:29.82Z" }, - { url = "https://files.pythonhosted.org/packages/f8/2d/25d9b566d10cab1c42b3b9e5b11ef79c9111eaf4463b8c257a3bd89e0ead/multidict-6.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a027ec240fe73a8d6281872690b988eed307cd7d91b23998ff35ff577ca688b5", size = 257539, upload-time = "2025-10-06T14:50:31.731Z" }, - { url = "https://files.pythonhosted.org/packages/b6/b1/8d1a965e6637fc33de3c0d8f414485c2b7e4af00f42cab3d84e7b955c222/multidict-6.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1d964afecdf3a8288789df2f5751dc0a8261138c3768d9af117ed384e538fad", size = 256345, upload-time = "2025-10-06T14:50:33.26Z" }, - { url = "https://files.pythonhosted.org/packages/ba/0c/06b5a8adbdeedada6f4fb8d8f193d44a347223b11939b42953eeb6530b6b/multidict-6.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caf53b15b1b7df9fbd0709aa01409000a2b4dd03a5f6f5cc548183c7c8f8b63c", size = 247934, upload-time = "2025-10-06T14:50:34.808Z" }, - { url = "https://files.pythonhosted.org/packages/8f/31/b2491b5fe167ca044c6eb4b8f2c9f3b8a00b24c432c365358eadac5d7625/multidict-6.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:654030da3197d927f05a536a66186070e98765aa5142794c9904555d3a9d8fb5", size = 245243, upload-time = "2025-10-06T14:50:36.436Z" }, - { url = "https://files.pythonhosted.org/packages/61/1a/982913957cb90406c8c94f53001abd9eafc271cb3e70ff6371590bec478e/multidict-6.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2090d3718829d1e484706a2f525e50c892237b2bf9b17a79b059cb98cddc2f10", size = 235878, upload-time = "2025-10-06T14:50:37.953Z" }, - { url = "https://files.pythonhosted.org/packages/be/c0/21435d804c1a1cf7a2608593f4d19bca5bcbd7a81a70b253fdd1c12af9c0/multidict-6.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d2cfeec3f6f45651b3d408c4acec0ebf3daa9bc8a112a084206f5db5d05b754", size = 243452, upload-time = "2025-10-06T14:50:39.574Z" }, - { url = "https://files.pythonhosted.org/packages/54/0a/4349d540d4a883863191be6eb9a928846d4ec0ea007d3dcd36323bb058ac/multidict-6.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef089f985b8c194d341eb2c24ae6e7408c9a0e2e5658699c92f497437d88c3c", size = 252312, upload-time = "2025-10-06T14:50:41.612Z" }, - { url = "https://files.pythonhosted.org/packages/26/64/d5416038dbda1488daf16b676e4dbfd9674dde10a0cc8f4fc2b502d8125d/multidict-6.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e93a0617cd16998784bf4414c7e40f17a35d2350e5c6f0bd900d3a8e02bd3762", size = 246935, upload-time = "2025-10-06T14:50:43.972Z" }, - { url = "https://files.pythonhosted.org/packages/9f/8c/8290c50d14e49f35e0bd4abc25e1bc7711149ca9588ab7d04f886cdf03d9/multidict-6.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0feece2ef8ebc42ed9e2e8c78fc4aa3cf455733b507c09ef7406364c94376c6", size = 243385, upload-time = "2025-10-06T14:50:45.648Z" }, - { url = "https://files.pythonhosted.org/packages/ef/a0/f83ae75e42d694b3fbad3e047670e511c138be747bc713cf1b10d5096416/multidict-6.7.0-cp313-cp313t-win32.whl", hash = "sha256:19a1d55338ec1be74ef62440ca9e04a2f001a04d0cc49a4983dc320ff0f3212d", size = 47777, upload-time = "2025-10-06T14:50:47.154Z" }, - { url = "https://files.pythonhosted.org/packages/dc/80/9b174a92814a3830b7357307a792300f42c9e94664b01dee8e457551fa66/multidict-6.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3da4fb467498df97e986af166b12d01f05d2e04f978a9c1c680ea1988e0bc4b6", size = 53104, upload-time = "2025-10-06T14:50:48.851Z" }, - { url = "https://files.pythonhosted.org/packages/cc/28/04baeaf0428d95bb7a7bea0e691ba2f31394338ba424fb0679a9ed0f4c09/multidict-6.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:b4121773c49a0776461f4a904cdf6264c88e42218aaa8407e803ca8025872792", size = 45503, upload-time = "2025-10-06T14:50:50.16Z" }, - { url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317, upload-time = "2025-10-06T14:52:29.272Z" }, +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] [[package]] @@ -530,11 +541,11 @@ wheels = [ [[package]] name = "packaging" -version = "25.0" +version = "26.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, ] [[package]] @@ -596,11 +607,11 @@ wheels = [ [[package]] name = "pycparser" -version = "2.23" +version = "3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] [[package]] @@ -649,7 +660,7 @@ requires-dist = [ { name = "sphinx", specifier = ">=9.0" }, { name = "sphinx-autobuild", specifier = ">=2025.0.0,<2026.0.0" }, { name = "sphinx-multiversion-scylla", specifier = ">=0.3.2,<1.0.0" }, - { name = "sphinx-scylladb-theme", specifier = ">=1.9.1" }, + { name = "sphinx-scylladb-theme", specifier = ">=1.9.2" }, { name = "sphinx-sitemap", specifier = ">=2.8.0,<3.0.0" }, { name = "tornado", specifier = ">=6.5,<7.0" }, ] @@ -690,7 +701,7 @@ wheels = [ [[package]] name = "requests" -version = "2.32.5" +version = "2.33.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -698,22 +709,22 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, ] [[package]] name = "rich" -version = "14.2.0" +version = "15.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] [[package]] @@ -727,11 +738,11 @@ wheels = [ [[package]] name = "setuptools" -version = "80.9.0" +version = "82.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, ] [[package]] @@ -763,11 +774,11 @@ wheels = [ [[package]] name = "soupsieve" -version = "2.8" +version = "2.8.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472, upload-time = "2025-08-27T15:39:51.78Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" }, + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, ] [[package]] @@ -853,14 +864,14 @@ wheels = [ [[package]] name = "sphinx-multiversion-scylla" -version = "0.3.7" +version = "0.3.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sphinx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/b1/83fb37f6c9038469b3bd01453875bb2127b3c03f9f41247394ad2063645c/sphinx_multiversion_scylla-0.3.7.tar.gz", hash = "sha256:fc1ddd58e82cfd8810c1be6db8717a244043c04c1c632e9bd1436415d1db0d3b", size = 12665, upload-time = "2026-02-27T18:43:17.849Z" } +sdist = { url = "https://files.pythonhosted.org/packages/83/b7/ca070ac96cbca8b91c499827790ca4816a259b9259a961108a3fd9fa470c/sphinx_multiversion_scylla-0.3.8.tar.gz", hash = "sha256:418b563afd3c75c40f096b614cb4c595928692fb0b340762e5fd19c876567040", size = 12749, upload-time = "2026-04-13T16:45:16.119Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/94/f5b6219ca1136dc0305aaf3fb6c96aa2dfe65224d6dc147e00a6485a1a22/sphinx_multiversion_scylla-0.3.7-py3-none-any.whl", hash = "sha256:6205d261a77c90b7ea3105311d1d56014736a5148966133c34344512bb8c4e4f", size = 12558, upload-time = "2026-02-27T18:43:16.988Z" }, + { url = "https://files.pythonhosted.org/packages/67/1d/c4dea80220e2cab5dc326da2369ecac60b5fc71cb0643df698ef76bb14bb/sphinx_multiversion_scylla-0.3.8-py3-none-any.whl", hash = "sha256:a3a16724eb5ec76563f12dd66efb2e7c5d9aa2ddf5e8bb755e817f9973b44ffd", size = 12675, upload-time = "2026-04-13T16:45:14.859Z" }, ] [[package]] @@ -877,7 +888,7 @@ wheels = [ [[package]] name = "sphinx-scylladb-theme" -version = "1.9.1" +version = "1.9.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "beautifulsoup4" }, @@ -890,9 +901,9 @@ dependencies = [ { name = "sphinx-tabs" }, { name = "sphinxcontrib-mermaid" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d7/4e/e49e351d4c429b8fe3090657d39e956d53dff61187d783caac1cba81bd72/sphinx_scylladb_theme-1.9.1.tar.gz", hash = "sha256:2ba6367f005d2c68eee1916cc16385989b8e53bbddcc81193003bdeb3bd3415e", size = 1676201, upload-time = "2026-03-09T18:10:43.841Z" } +sdist = { url = "https://files.pythonhosted.org/packages/31/92/e30549be27dfdbfb3a1bf52cbc5496c190230dd2d4e7a41c8bafada8f4a2/sphinx_scylladb_theme-1.9.2.tar.gz", hash = "sha256:f4319deeefcc446779375c2d9cbdd922eaf63da092a50def74247dd2156f1274", size = 1683295, upload-time = "2026-04-14T11:07:30.662Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/30/2b2bae1b022d1fabef405a4857f160464548e08d924f24d0b26d0ca6a848/sphinx_scylladb_theme-1.9.1-py3-none-any.whl", hash = "sha256:6156d60befc3da03bd11991fec9bc590e27ce7cc4ab05aa334edd5611424b106", size = 1662204, upload-time = "2026-03-09T18:10:45.638Z" }, + { url = "https://files.pythonhosted.org/packages/4d/ff/9957eef93c1b46dbbccd66cb4766d513c1061961daaa60fcfc1a78b3bc20/sphinx_scylladb_theme-1.9.2-py3-none-any.whl", hash = "sha256:1d75463151693c3b31ef48b2401aa4db18953fc515b4061c6f127182242e0280", size = 1669961, upload-time = "2026-04-14T11:07:28.944Z" }, ] [[package]] @@ -909,7 +920,7 @@ wheels = [ [[package]] name = "sphinx-substitution-extensions" -version = "2025.11.17" +version = "2026.1.12" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "beartype" }, @@ -917,23 +928,23 @@ dependencies = [ { name = "myst-parser" }, { name = "sphinx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0e/53/feccf1b607de2aef65c6411b4b4a34a91aa8daf397e77258a7774f9d1990/sphinx_substitution_extensions-2025.11.17.tar.gz", hash = "sha256:aae17f8db9efc3d454a304373ae3df763f8739e05e0b98d5381db46f6d250b27", size = 30459, upload-time = "2025-11-17T14:34:45.072Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/3e/a82aa5fed0d06161a89dc2f6971b160f837cad44f196c467fc6b2132acaa/sphinx_substitution_extensions-2026.1.12.tar.gz", hash = "sha256:25e0c6c40fbf9e1df593883da946879044a3bf8d85652c8c58f354a53575d736", size = 31676, upload-time = "2026-01-12T06:19:35.324Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/82/df/7e9cd4775c2782c894741c9274cc4c596ad02ab31257e5a5417f0a6af893/sphinx_substitution_extensions-2025.11.17-py2.py3-none-any.whl", hash = "sha256:ac18455bdc8324b337b0fe7498c1c0d0b1cb65c74d131459be4dea9edb6abbef", size = 8741, upload-time = "2025-11-17T14:34:43.66Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5e/9caa7167d2ef2b60326765150d64513be1b61b1864ad58a92683578b2776/sphinx_substitution_extensions-2026.1.12-py2.py3-none-any.whl", hash = "sha256:9152beb4f0f5cab52057681b376a473fa6b997defc85d4ac154dd12b13a3e987", size = 8766, upload-time = "2026-01-12T06:19:33.541Z" }, ] [[package]] name = "sphinx-tabs" -version = "3.4.7" +version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docutils" }, { name = "pygments" }, { name = "sphinx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6a/53/a9a91995cb365e589f413b77fc75f1c0e9b4ac61bfa8da52a779ad855cc0/sphinx-tabs-3.4.7.tar.gz", hash = "sha256:991ad4a424ff54119799ba1491701aa8130dd43509474aef45a81c42d889784d", size = 15891, upload-time = "2024-10-08T13:37:27.887Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/30/ca5b0de830f369968d8e3483dd45a8908fd10169c05cd9837f0bd075982e/sphinx_tabs-3.5.0.tar.gz", hash = "sha256:91dba1187e4c35fd37380a56ac228bbd54c6c649b2351829f3bf033718277537", size = 17006, upload-time = "2026-03-03T23:00:30.404Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/c6/f47505b564b918a3ba60c1e99232d4942c4a7e44ecaae603e829e3d05dae/sphinx_tabs-3.4.7-py3-none-any.whl", hash = "sha256:c12d7a36fd413b369e9e9967a0a4015781b71a9c393575419834f19204bd1915", size = 9727, upload-time = "2024-10-08T13:37:26.192Z" }, + { url = "https://files.pythonhosted.org/packages/2e/45/6adc5efeb19fd5fed4027e520b5c668ce58236a2b271ade5533c4c116276/sphinx_tabs-3.5.0-py3-none-any.whl", hash = "sha256:154be49de4d5c8249ea08c5d9bf88ca8f9c31e00a178305a93cbc33e000339e5", size = 9871, upload-time = "2026-03-03T23:00:28.89Z" }, ] [[package]] @@ -974,15 +985,16 @@ wheels = [ [[package]] name = "sphinxcontrib-mermaid" -version = "1.2.3" +version = "2.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "jinja2" }, { name = "pyyaml" }, { name = "sphinx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f5/49/c6ddfe709a4ab76ac6e5a00e696f73626b2c189dc1e1965a361ec102e6cc/sphinxcontrib_mermaid-1.2.3.tar.gz", hash = "sha256:358699d0ec924ef679b41873d9edd97d0773446daf9760c75e18dc0adfd91371", size = 18885, upload-time = "2025-11-26T04:18:32.43Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/ae/999891de292919b66ea34f2c22fc22c9be90ab3536fbc0fca95716277351/sphinxcontrib_mermaid-2.0.1.tar.gz", hash = "sha256:a21a385a059a6cafd192aa3a586b14bf5c42721e229db67b459dc825d7f0a497", size = 19839, upload-time = "2026-03-05T14:10:41.901Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/39/8b54299ffa00e597d3b0b4d042241a0a0b22cb429ad007ccfb9c1745b4d1/sphinxcontrib_mermaid-1.2.3-py3-none-any.whl", hash = "sha256:5be782b27026bef97bfb15ccb2f7868b674a1afc0982b54cb149702cfc25aa02", size = 13413, upload-time = "2025-11-26T04:18:31.269Z" }, + { url = "https://files.pythonhosted.org/packages/03/46/25d64bcd7821c8d6f1080e1c43d5fcdfc442a18f759a230b5ccdc891093e/sphinxcontrib_mermaid-2.0.1-py3-none-any.whl", hash = "sha256:9dca7fbe827bad5e7e2b97c4047682cfd26e3e07398cfdc96c7a8842ae7f06e7", size = 14064, upload-time = "2026-03-05T14:10:40.533Z" }, ] [[package]] @@ -1005,14 +1017,14 @@ wheels = [ [[package]] name = "starlette" -version = "0.50.0" +version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, ] [[package]] @@ -1043,17 +1055,17 @@ wheels = [ [[package]] name = "typer" -version = "0.20.0" +version = "0.24.1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "annotated-doc" }, { name = "click" }, { name = "rich" }, { name = "shellingham" }, - { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8f/28/7c85c8032b91dbe79725b6f17d2fffc595dff06a35c7a30a37bef73a1ab4/typer-0.20.0.tar.gz", hash = "sha256:1aaf6494031793e4876fb0bacfa6a912b551cf43c1e63c800df8b1a866720c37", size = 106492, upload-time = "2025-10-20T17:03:49.445Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl", hash = "sha256:5b463df6793ec1dca6213a3cf4c0f03bc6e322ac5e16e13ddd622a889489784a", size = 47028, upload-time = "2025-10-20T17:03:47.617Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, ] [[package]] @@ -1076,15 +1088,15 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.38.0" +version = "0.44.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/da/6eee1ff8b6cbeed47eeb5229749168e81eb4b7b999a1a15a7176e51410c9/uvicorn-0.44.0.tar.gz", hash = "sha256:6c942071b68f07e178264b9152f1f16dfac5da85880c4ce06366a96d70d4f31e", size = 86947, upload-time = "2026-04-06T09:23:22.826Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" }, + { url = "https://files.pythonhosted.org/packages/b7/23/a5bbd9600dd607411fa644c06ff4951bec3a4d82c4b852374024359c19c0/uvicorn-0.44.0-py3-none-any.whl", hash = "sha256:ce937c99a2cc70279556967274414c087888e8cec9f9c94644dfca11bd3ced89", size = 69425, upload-time = "2026-04-06T09:23:21.524Z" }, ] [[package]] @@ -1123,68 +1135,70 @@ wheels = [ [[package]] name = "websockets" -version = "15.0.1" +version = "16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, - { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, - { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, - { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, - { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, - { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, - { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, - { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, - { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] [[package]] name = "yarl" -version = "1.22.0" +version = "1.23.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" }, - { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" }, - { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" }, - { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" }, - { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" }, - { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" }, - { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" }, - { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" }, - { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" }, - { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" }, - { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" }, - { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" }, - { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" }, - { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" }, - { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" }, - { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" }, - { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" }, - { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" }, - { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" }, - { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" }, - { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" }, - { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" }, - { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" }, - { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" }, - { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" }, - { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" }, - { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" }, - { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" }, - { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" }, - { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" }, - { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" }, - { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" }, + { url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" }, + { url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" }, + { url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" }, + { url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" }, + { url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" }, + { url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" }, + { url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" }, + { url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" }, + { url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" }, + { url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" }, + { url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" }, + { url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" }, + { url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" }, + { url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" }, + { url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" }, + { url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" }, + { url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" }, + { url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" }, + { url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" }, + { url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" }, + { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, ] [[package]] @@ -1198,14 +1212,14 @@ wheels = [ [[package]] name = "zope-interface" -version = "8.1.1" +version = "8.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/71/c9/5ec8679a04d37c797d343f650c51ad67d178f0001c363e44b6ac5f97a9da/zope_interface-8.1.1.tar.gz", hash = "sha256:51b10e6e8e238d719636a401f44f1e366146912407b58453936b781a19be19ec", size = 254748, upload-time = "2025-11-15T08:32:52.404Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/04/0b1d92e7d31507c5fbe203d9cc1ae80fb0645688c7af751ea0ec18c2223e/zope_interface-8.3.tar.gz", hash = "sha256:e1a9de7d0b5b5c249a73b91aebf4598ce05e334303af6aa94865893283e9ff10", size = 256822, upload-time = "2026-04-10T06:12:35.036Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/85/81/3c3b5386ce4fba4612fd82ffb8a90d76bcfea33ca2b6399f21e94d38484f/zope_interface-8.1.1-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:84f9be6d959640de9da5d14ac1f6a89148b16da766e88db37ed17e936160b0b1", size = 209046, upload-time = "2025-11-15T08:37:01.473Z" }, - { url = "https://files.pythonhosted.org/packages/4a/e3/32b7cb950c4c4326b3760a8e28e5d6f70ad15f852bfd8f9364b58634f74b/zope_interface-8.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:531fba91dcb97538f70cf4642a19d6574269460274e3f6004bba6fe684449c51", size = 209104, upload-time = "2025-11-15T08:37:02.887Z" }, - { url = "https://files.pythonhosted.org/packages/a3/3d/c4c68e1752a5f5effa2c1f5eaa4fea4399433c9b058fb7000a34bfb1c447/zope_interface-8.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:fc65f5633d5a9583ee8d88d1f5de6b46cd42c62e47757cfe86be36fb7c8c4c9b", size = 259277, upload-time = "2025-11-15T08:37:04.389Z" }, - { url = "https://files.pythonhosted.org/packages/fd/5b/cf4437b174af7591ee29bbad728f620cab5f47bd6e9c02f87d59f31a0dda/zope_interface-8.1.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:efef80ddec4d7d99618ef71bc93b88859248075ca2e1ae1c78636654d3d55533", size = 264742, upload-time = "2025-11-15T08:37:05.613Z" }, - { url = "https://files.pythonhosted.org/packages/0b/0e/0cf77356862852d3d3e62db9aadae5419a1a7d89bf963b219745283ab5ca/zope_interface-8.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49aad83525eca3b4747ef51117d302e891f0042b06f32aa1c7023c62642f962b", size = 264252, upload-time = "2025-11-15T08:37:07.035Z" }, - { url = "https://files.pythonhosted.org/packages/8a/10/2af54aa88b2fa172d12364116cc40d325fedbb1877c3bb031b0da6052855/zope_interface-8.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:71cf329a21f98cb2bd9077340a589e316ac8a415cac900575a32544b3dffcb98", size = 212330, upload-time = "2025-11-15T08:37:08.14Z" }, + { url = "https://files.pythonhosted.org/packages/27/da/ff205c5463e52ad64cc40be667fdff2b01b9754a385c6b95bac01645fa4f/zope_interface-8.3-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:1aa0e1d72212cedc38b2156bbca08cf24625c057135a7947ef6b19bc732b2772", size = 211889, upload-time = "2026-04-10T06:22:27.612Z" }, + { url = "https://files.pythonhosted.org/packages/c7/21/0cc848e22769b1cf4c0cd636ec2e60ea05cfb958423435ea526d5a291fe8/zope_interface-8.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54ab83218a8f6947ba4b6cb1a121f1e1abe2e418b838ccdac71639d0f97e734e", size = 211961, upload-time = "2026-04-10T06:22:29.575Z" }, + { url = "https://files.pythonhosted.org/packages/e3/54/815c9dbb90336c50694b4c7ef7ced06bc389e5597200c77457b557a0221c/zope_interface-8.3-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:34d6c10fa790005487c471e0e4ab537b0fa9a70e55a96994e51ffeef92205fa4", size = 264409, upload-time = "2026-04-10T06:22:31.426Z" }, + { url = "https://files.pythonhosted.org/packages/3a/69/2e5c30adde0e94552d934971fa6eba107449d3d11fa086cfcfeb8ea6354d/zope_interface-8.3-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:93108d5f8dee20177a637438bf4df4c6faf8a317c9d4a8b1d5e78123854e3317", size = 269592, upload-time = "2026-04-10T06:22:33.393Z" }, + { url = "https://files.pythonhosted.org/packages/23/8a/fbb1dceb5c5400b2b27934aa102d29fe4cb06732122e7f409efebeb6e097/zope_interface-8.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8f81d90f80b9fbf36602549e2f187861c9d7139837f8c9dd685ce3b933c6360f", size = 269548, upload-time = "2026-04-10T06:22:35.339Z" }, + { url = "https://files.pythonhosted.org/packages/a2/70/abd0bb9cc9b1a9a718f30c81f46a184a2e751dd80cf57db142ffa42730da/zope_interface-8.3-cp313-cp313-win_amd64.whl", hash = "sha256:96106a5f609bb355e1aec6ab0361213c8af0843ca1e1ba9c42eacfbd0910914e", size = 214391, upload-time = "2026-04-10T06:22:36.969Z" }, ] From 766898db4396cf28dd3f70e1831f70f207bbed38 Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Wed, 3 Jun 2026 10:15:23 +0300 Subject: [PATCH 089/200] Remove unused imports from driver code --- cassandra/cluster.py | 5 ++--- cassandra/concurrent.py | 1 - cassandra/connection.py | 2 +- cassandra/cqlengine/connection.py | 2 +- cassandra/cqlengine/query.py | 2 +- cassandra/cqltypes.py | 2 +- cassandra/datastax/cloud/__init__.py | 1 - cassandra/encoder.py | 1 - cassandra/io/asyncioreactor.py | 2 +- cassandra/io/asyncorereactor.py | 1 - cassandra/pool.py | 2 -- cassandra/protocol.py | 19 +++++++++++-------- 12 files changed, 18 insertions(+), 22 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 1181c6f686..6a8a6350e6 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -45,8 +45,7 @@ import weakref from weakref import WeakValueDictionary -from cassandra import (ConsistencyLevel, AuthenticationFailed, InvalidRequest, - OperationTimedOut, UnsupportedOperation, +from cassandra import (ConsistencyLevel, AuthenticationFailed, OperationTimedOut, UnsupportedOperation, SchemaTargetType, DriverException, ProtocolVersion, UnresolvableContactPoints, DependencyException) from cassandra.auth import _proxy_execute_key, PlainTextAuthProvider @@ -85,7 +84,7 @@ named_tuple_factory, dict_factory, tuple_factory, FETCH_SIZE_UNSET, HostTargetingStatement) from cassandra.marshal import int64_pack -from cassandra.tablets import Tablet, Tablets +from cassandra.tablets import Tablet from cassandra.timestamps import MonotonicTimestampGenerator from cassandra.util import _resolve_contact_points_to_string_map, Version, maybe_add_timeout_to_query diff --git a/cassandra/concurrent.py b/cassandra/concurrent.py index b96d0b12d4..0e7bf794e0 100644 --- a/cassandra/concurrent.py +++ b/cassandra/concurrent.py @@ -17,7 +17,6 @@ from heapq import heappush, heappop from itertools import cycle from threading import Condition -import sys from cassandra.cluster import ResultSet, EXEC_PROFILE_DEFAULT diff --git a/cassandra/connection.py b/cassandra/connection.py index eae018649b..25508e32ac 100644 --- a/cassandra/connection.py +++ b/cassandra/connection.py @@ -51,7 +51,7 @@ RegisterMessage, ReviseRequestMessage) from cassandra.segment import SegmentCodec, CrcException from cassandra.util import OrderedDict -from cassandra.shard_info import ShardingInfo +from cassandra.shard_info import ShardingInfo # noqa: F401 # re-exported for cassandra.connection.ShardingInfo log = logging.getLogger(__name__) diff --git a/cassandra/cqlengine/connection.py b/cassandra/cqlengine/connection.py index bf3e55a2e8..c48f8fef90 100644 --- a/cassandra/cqlengine/connection.py +++ b/cassandra/cqlengine/connection.py @@ -16,7 +16,7 @@ import logging import threading -from cassandra.cluster import Cluster, _ConfigMode, _NOT_SET, NoHostAvailable, UserTypeDoesNotExist, ConsistencyLevel +from cassandra.cluster import Cluster, _ConfigMode, _NOT_SET, NoHostAvailable, UserTypeDoesNotExist from cassandra.query import SimpleStatement, dict_factory from cassandra.cqlengine import CQLEngineException diff --git a/cassandra/cqlengine/query.py b/cassandra/cqlengine/query.py index afc7ceeef6..f99b953c16 100644 --- a/cassandra/cqlengine/query.py +++ b/cassandra/cqlengine/query.py @@ -18,7 +18,7 @@ import time from warnings import warn -from cassandra.query import SimpleStatement, BatchType as CBatchType, BatchStatement +from cassandra.query import SimpleStatement, BatchType as CBatchType from cassandra.cqlengine import columns, CQLEngineException, ValidationError, UnicodeMixin from cassandra.cqlengine import connection as conn from cassandra.cqlengine.functions import Token, BaseQueryFunction, QueryValue diff --git a/cassandra/cqltypes.py b/cassandra/cqltypes.py index 547a13c979..99018eef03 100644 --- a/cassandra/cqltypes.py +++ b/cassandra/cqltypes.py @@ -44,7 +44,7 @@ from uuid import UUID from cassandra.marshal import (int8_pack, int8_unpack, int16_pack, int16_unpack, - uint16_pack, uint16_unpack, uint32_pack, uint32_unpack, + uint16_unpack, uint32_pack, uint32_unpack, int32_pack, int32_unpack, int64_pack, int64_unpack, float_pack, float_unpack, double_pack, double_unpack, varint_pack, varint_unpack, point_be, point_le, diff --git a/cassandra/datastax/cloud/__init__.py b/cassandra/datastax/cloud/__init__.py index 0f042ff1c8..be79d6db38 100644 --- a/cassandra/datastax/cloud/__init__.py +++ b/cassandra/datastax/cloud/__init__.py @@ -15,7 +15,6 @@ import os import logging import json -import sys import tempfile import shutil from urllib.request import urlopen diff --git a/cassandra/encoder.py b/cassandra/encoder.py index d803c087ba..b33be935df 100644 --- a/cassandra/encoder.py +++ b/cassandra/encoder.py @@ -25,7 +25,6 @@ import calendar import datetime import math -import sys import types from uuid import UUID import ipaddress diff --git a/cassandra/io/asyncioreactor.py b/cassandra/io/asyncioreactor.py index 452667c8eb..92ab972e7d 100644 --- a/cassandra/io/asyncioreactor.py +++ b/cassandra/io/asyncioreactor.py @@ -7,7 +7,7 @@ import os import socket import ssl -from threading import Lock, Thread, get_ident +from threading import Lock, Thread log = logging.getLogger(__name__) diff --git a/cassandra/io/asyncorereactor.py b/cassandra/io/asyncorereactor.py index 02466ad0d2..4d19bb9849 100644 --- a/cassandra/io/asyncorereactor.py +++ b/cassandra/io/asyncorereactor.py @@ -20,7 +20,6 @@ import sys from threading import Lock, Thread, Event import time -import weakref import sys import ssl diff --git a/cassandra/pool.py b/cassandra/pool.py index 9e949c342c..18bed1bbdc 100644 --- a/cassandra/pool.py +++ b/cassandra/pool.py @@ -18,11 +18,9 @@ from concurrent.futures import Future from functools import total_ordering import logging -import socket import time import random import copy -import uuid from threading import Lock, RLock, Condition import weakref try: diff --git a/cassandra/protocol.py b/cassandra/protocol.py index 4628c7ee0e..bb2865ee53 100644 --- a/cassandra/protocol.py +++ b/cassandra/protocol.py @@ -27,14 +27,17 @@ AlreadyExists, InvalidRequest, Unauthorized, UnsupportedOperation, UserFunctionDescriptor, UserAggregateDescriptor, SchemaTargetType) -from cassandra.cqltypes import (AsciiType, BytesType, BooleanType, - CounterColumnType, DateType, DecimalType, - DoubleType, FloatType, Int32Type, - InetAddressType, IntegerType, ListType, - LongType, MapType, SetType, TimeUUIDType, - UTF8Type, VarcharType, UUIDType, UserType, - TupleType, lookup_casstype, SimpleDateType, - TimeType, ByteType, ShortType, DurationType) +# NOTE: many of these names are not referenced directly, but are required in module +# scope because ResultMessage.type_codes resolves them dynamically via globals()[name] +# (see the type_codes mapping below). Do not remove as "unused imports". +from cassandra.cqltypes import (AsciiType, BytesType, BooleanType, # noqa: F401 + CounterColumnType, DateType, DecimalType, # noqa: F401 + DoubleType, FloatType, Int32Type, # noqa: F401 + InetAddressType, IntegerType, ListType, # noqa: F401 + LongType, MapType, SetType, TimeUUIDType, # noqa: F401 + UTF8Type, VarcharType, UUIDType, UserType, # noqa: F401 + TupleType, lookup_casstype, SimpleDateType, # noqa: F401 + TimeType, ByteType, ShortType, DurationType) # noqa: F401 from cassandra.marshal import (int32_pack, int32_unpack, uint16_pack, uint16_unpack, uint8_pack, int8_unpack, uint64_pack, v3_header_pack, uint32_pack, uint32_le_unpack, uint32_le_pack) From e48f3c61e66c51b1d29fec649f64c599fa5e0d94 Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Wed, 3 Jun 2026 10:30:18 +0300 Subject: [PATCH 090/200] Remove unused imports from tests --- tests/integration/cqlengine/__init__.py | 1 - tests/integration/cqlengine/base.py | 1 - .../cqlengine/connections/test_connection.py | 2 +- .../cqlengine/management/test_management.py | 1 - tests/integration/cqlengine/model/test_model.py | 1 - tests/integration/cqlengine/model/test_model_io.py | 1 - tests/integration/cqlengine/query/test_named.py | 3 +-- .../cqlengine/statements/test_select_statement.py | 2 +- .../cqlengine/statements/test_update_statement.py | 4 +--- tests/integration/cqlengine/test_connections.py | 4 ++-- tests/integration/cqlengine/test_ifexists.py | 2 +- tests/integration/cqlengine/test_ttl.py | 3 +-- tests/integration/long/test_large_data.py | 2 +- tests/integration/simulacron/__init__.py | 1 - tests/integration/simulacron/test_cluster.py | 7 ++----- tests/integration/simulacron/test_connection.py | 1 - tests/integration/simulacron/test_empty_column.py | 2 -- tests/integration/simulacron/test_endpoint.py | 1 - tests/integration/simulacron/utils.py | 1 - tests/integration/standard/conftest.py | 1 - tests/integration/standard/test_cluster.py | 6 +++--- tests/integration/standard/test_concurrent.py | 1 - .../test_concurrent_schema_change_and_node_kill.py | 1 - tests/integration/standard/test_custom_cluster.py | 2 +- tests/integration/standard/test_custom_payload.py | 2 +- .../standard/test_custom_protocol_handler.py | 7 ++----- tests/integration/standard/test_metadata.py | 11 ++--------- tests/integration/standard/test_policies.py | 4 +--- tests/integration/standard/test_query.py | 2 +- tests/integration/standard/test_query_paging.py | 1 - tests/integration/standard/test_shard_aware.py | 2 +- tests/integration/standard/test_single_interface.py | 4 +--- tests/integration/standard/test_types.py | 4 +--- tests/integration/upgrade/__init__.py | 2 +- tests/integration/upgrade/test_upgrade.py | 1 - tests/stress_tests/test_load.py | 1 - tests/unit/advanced/test_graph.py | 2 +- tests/unit/advanced/test_insights.py | 8 +------- tests/unit/advanced/test_metadata.py | 2 +- tests/unit/io/test_asyncorereactor.py | 1 - tests/unit/io/test_twistedreactor.py | 1 - tests/unit/test_endpoints.py | 2 +- tests/unit/test_host_connection_pool.py | 1 - tests/unit/test_marshalling.py | 1 - tests/unit/test_protocol.py | 4 ---- 45 files changed, 30 insertions(+), 84 deletions(-) diff --git a/tests/integration/cqlengine/__init__.py b/tests/integration/cqlengine/__init__.py index 7fae437370..802bf77d19 100644 --- a/tests/integration/cqlengine/__init__.py +++ b/tests/integration/cqlengine/__init__.py @@ -13,7 +13,6 @@ # limitations under the License. import os -import unittest from cassandra import ConsistencyLevel from cassandra.cqlengine import connection diff --git a/tests/integration/cqlengine/base.py b/tests/integration/cqlengine/base.py index c65554b974..29297720da 100644 --- a/tests/integration/cqlengine/base.py +++ b/tests/integration/cqlengine/base.py @@ -13,7 +13,6 @@ # limitations under the License. import unittest -import sys from cassandra.cqlengine.connection import get_session from cassandra.cqlengine.models import Model diff --git a/tests/integration/cqlengine/connections/test_connection.py b/tests/integration/cqlengine/connections/test_connection.py index 640c953285..957acaa417 100644 --- a/tests/integration/cqlengine/connections/test_connection.py +++ b/tests/integration/cqlengine/connections/test_connection.py @@ -23,7 +23,7 @@ from cassandra.policies import RoundRobinPolicy from cassandra.query import dict_factory -from tests.integration import CASSANDRA_IP, PROTOCOL_VERSION, execute_with_long_wait_retry, local, TestCluster +from tests.integration import CASSANDRA_IP, execute_with_long_wait_retry, local, TestCluster from tests.integration.cqlengine.base import BaseCassEngTestCase from tests.integration.cqlengine import DEFAULT_KEYSPACE, setup_connection diff --git a/tests/integration/cqlengine/management/test_management.py b/tests/integration/cqlengine/management/test_management.py index 1332680cef..23ddefb639 100644 --- a/tests/integration/cqlengine/management/test_management.py +++ b/tests/integration/cqlengine/management/test_management.py @@ -14,7 +14,6 @@ import unittest from unittest import mock -import logging from packaging.version import Version from cassandra.cqlengine.connection import get_session, get_cluster from cassandra.cqlengine import CQLEngineException diff --git a/tests/integration/cqlengine/model/test_model.py b/tests/integration/cqlengine/model/test_model.py index 98d71993fd..1bdd373c28 100644 --- a/tests/integration/cqlengine/model/test_model.py +++ b/tests/integration/cqlengine/model/test_model.py @@ -20,7 +20,6 @@ from cassandra.cqlengine import models from cassandra.cqlengine.models import Model, ModelDefinitionException from uuid import uuid1 -from tests.integration import pypy from tests.integration.cqlengine.base import TestQueryUpdateModel import pytest diff --git a/tests/integration/cqlengine/model/test_model_io.py b/tests/integration/cqlengine/model/test_model_io.py index f55815310a..a575e86cf8 100644 --- a/tests/integration/cqlengine/model/test_model_io.py +++ b/tests/integration/cqlengine/model/test_model_io.py @@ -19,7 +19,6 @@ from decimal import Decimal from operator import itemgetter -import cassandra from cassandra.cqlengine import columns from cassandra.cqlengine import CQLEngineException from cassandra.cqlengine.management import sync_table diff --git a/tests/integration/cqlengine/query/test_named.py b/tests/integration/cqlengine/query/test_named.py index 66ba8b973a..70df912428 100644 --- a/tests/integration/cqlengine/query/test_named.py +++ b/tests/integration/cqlengine/query/test_named.py @@ -12,7 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import unittest from cassandra import ConsistencyLevel from cassandra.cqlengine import operators @@ -22,7 +21,7 @@ from cassandra.concurrent import execute_concurrent_with_args from cassandra.cqlengine import models -from tests.integration.cqlengine import setup_connection, execute_count +from tests.integration.cqlengine import execute_count from tests.integration.cqlengine.base import BaseCassEngTestCase from tests.integration.cqlengine.query.test_queryset import BaseQuerySetUsage diff --git a/tests/integration/cqlengine/statements/test_select_statement.py b/tests/integration/cqlengine/statements/test_select_statement.py index b4bada1eb0..f1108b0bbc 100644 --- a/tests/integration/cqlengine/statements/test_select_statement.py +++ b/tests/integration/cqlengine/statements/test_select_statement.py @@ -14,7 +14,7 @@ import unittest from cassandra.cqlengine.columns import Column -from cassandra.cqlengine.statements import SelectStatement, WhereClause +from cassandra.cqlengine.statements import SelectStatement from cassandra.cqlengine.operators import * class SelectStatementTests(unittest.TestCase): diff --git a/tests/integration/cqlengine/statements/test_update_statement.py b/tests/integration/cqlengine/statements/test_update_statement.py index 6529b73558..5832002a26 100644 --- a/tests/integration/cqlengine/statements/test_update_statement.py +++ b/tests/integration/cqlengine/statements/test_update_statement.py @@ -15,9 +15,7 @@ from cassandra.cqlengine.columns import Column, Set, List, Text from cassandra.cqlengine.operators import * -from cassandra.cqlengine.statements import (UpdateStatement, WhereClause, - AssignmentClause, SetUpdateClause, - ListUpdateClause) +from cassandra.cqlengine.statements import (UpdateStatement) class UpdateStatementTests(unittest.TestCase): diff --git a/tests/integration/cqlengine/test_connections.py b/tests/integration/cqlengine/test_connections.py index 612255bdc5..a628195877 100644 --- a/tests/integration/cqlengine/test_connections.py +++ b/tests/integration/cqlengine/test_connections.py @@ -17,12 +17,12 @@ from cassandra.cqlengine import columns, CQLEngineException from cassandra.cqlengine import connection as conn from cassandra.cqlengine.management import drop_keyspace, sync_table, drop_table, create_keyspace_simple -from cassandra.cqlengine.models import Model, QuerySetDescriptor +from cassandra.cqlengine.models import Model from cassandra.cqlengine.query import ContextQuery, BatchQuery, ModelQuerySet from tests.integration.cqlengine import setup_connection, DEFAULT_KEYSPACE from tests.integration.cqlengine.base import BaseCassEngTestCase from tests.integration.cqlengine.query import test_queryset -from tests.integration import local, CASSANDRA_IP, TestCluster +from tests.integration import CASSANDRA_IP, TestCluster import pytest diff --git a/tests/integration/cqlengine/test_ifexists.py b/tests/integration/cqlengine/test_ifexists.py index 6c2ff437ab..26b0ba287b 100644 --- a/tests/integration/cqlengine/test_ifexists.py +++ b/tests/integration/cqlengine/test_ifexists.py @@ -18,7 +18,7 @@ from cassandra.cqlengine import columns from cassandra.cqlengine.management import sync_table, drop_table from cassandra.cqlengine.models import Model -from cassandra.cqlengine.query import BatchQuery, BatchType, LWTException, IfExistsWithCounterColumn +from cassandra.cqlengine.query import BatchQuery, LWTException, IfExistsWithCounterColumn from tests.integration.cqlengine.base import BaseCassEngTestCase from tests.integration import PROTOCOL_VERSION diff --git a/tests/integration/cqlengine/test_ttl.py b/tests/integration/cqlengine/test_ttl.py index df1afb6bf0..2d83fab6e3 100644 --- a/tests/integration/cqlengine/test_ttl.py +++ b/tests/integration/cqlengine/test_ttl.py @@ -13,7 +13,6 @@ # limitations under the License. -import unittest from packaging.version import Version @@ -25,7 +24,7 @@ from cassandra.cqlengine import columns from unittest import mock from cassandra.cqlengine.connection import get_session -from tests.integration import CASSANDRA_VERSION, greaterthancass20 +from tests.integration import CASSANDRA_VERSION class TestTTLModel(Model): diff --git a/tests/integration/long/test_large_data.py b/tests/integration/long/test_large_data.py index 0a1b368bf0..c6ddaea709 100644 --- a/tests/integration/long/test_large_data.py +++ b/tests/integration/long/test_large_data.py @@ -21,7 +21,7 @@ from cassandra.cluster import ExecutionProfile, EXEC_PROFILE_DEFAULT from cassandra.query import dict_factory from cassandra.query import SimpleStatement -from tests.integration import use_singledc, PROTOCOL_VERSION, TestCluster +from tests.integration import use_singledc, TestCluster from tests.integration.long.utils import create_schema import unittest diff --git a/tests/integration/simulacron/__init__.py b/tests/integration/simulacron/__init__.py index b75b67c540..671a862bab 100644 --- a/tests/integration/simulacron/__init__.py +++ b/tests/integration/simulacron/__init__.py @@ -18,7 +18,6 @@ clear_queries, start_and_prime_singledc, stop_simulacron, - start_and_prime_cluster_defaults, ) from cassandra.cluster import Cluster diff --git a/tests/integration/simulacron/test_cluster.py b/tests/integration/simulacron/test_cluster.py index 898734c416..b8b908e3bb 100644 --- a/tests/integration/simulacron/test_cluster.py +++ b/tests/integration/simulacron/test_cluster.py @@ -11,15 +11,12 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -import unittest -import logging -from packaging.version import Version import cassandra -from tests.integration.simulacron import SimulacronCluster, SimulacronBase +from tests.integration.simulacron import SimulacronCluster from tests.integration import (requiressimulacron, PROTOCOL_VERSION, MockLoggingHandler) -from tests.integration.simulacron.utils import prime_query, start_and_prime_singledc +from tests.integration.simulacron.utils import prime_query from cassandra import (WriteTimeout, WriteType, ConsistencyLevel, UnresolvableContactPoints) diff --git a/tests/integration/simulacron/test_connection.py b/tests/integration/simulacron/test_connection.py index ceceea814f..574f153edf 100644 --- a/tests/integration/simulacron/test_connection.py +++ b/tests/integration/simulacron/test_connection.py @@ -11,7 +11,6 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -import unittest import logging import time diff --git a/tests/integration/simulacron/test_empty_column.py b/tests/integration/simulacron/test_empty_column.py index daa9f20fa8..015f303d56 100644 --- a/tests/integration/simulacron/test_empty_column.py +++ b/tests/integration/simulacron/test_empty_column.py @@ -11,11 +11,9 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -import unittest from collections import namedtuple, OrderedDict -from cassandra import ProtocolVersion from cassandra.cluster import Cluster, EXEC_PROFILE_DEFAULT from cassandra.query import (named_tuple_factory, tuple_factory, dict_factory, ordered_dict_factory) diff --git a/tests/integration/simulacron/test_endpoint.py b/tests/integration/simulacron/test_endpoint.py index 5af38a9f6b..005d15a422 100644 --- a/tests/integration/simulacron/test_endpoint.py +++ b/tests/integration/simulacron/test_endpoint.py @@ -11,7 +11,6 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -import unittest from functools import total_ordering diff --git a/tests/integration/simulacron/utils.py b/tests/integration/simulacron/utils.py index 2322319234..9f6791be30 100644 --- a/tests/integration/simulacron/utils.py +++ b/tests/integration/simulacron/utils.py @@ -14,7 +14,6 @@ import json import subprocess -import time from urllib.request import build_opener, Request, HTTPHandler from cassandra.metadata import SchemaParserV4, SchemaParserDSE68 diff --git a/tests/integration/standard/conftest.py b/tests/integration/standard/conftest.py index 9934cfcbbb..ce73a10433 100644 --- a/tests/integration/standard/conftest.py +++ b/tests/integration/standard/conftest.py @@ -1,4 +1,3 @@ -import pytest import logging # Cluster topology groups for test ordering. diff --git a/tests/integration/standard/test_cluster.py b/tests/integration/standard/test_cluster.py index 00ea11ea27..9db4fede9e 100644 --- a/tests/integration/standard/test_cluster.py +++ b/tests/integration/standard/test_cluster.py @@ -27,10 +27,10 @@ import pytest import cassandra -from cassandra.cluster import NoHostAvailable, ExecutionProfile, EXEC_PROFILE_DEFAULT, ControlConnection, Cluster +from cassandra.cluster import NoHostAvailable, ExecutionProfile, EXEC_PROFILE_DEFAULT, Cluster from cassandra.concurrent import execute_concurrent from cassandra.policies import (RoundRobinPolicy, ExponentialReconnectionPolicy, - RetryPolicy, SimpleConvictionPolicy, HostDistance, + SimpleConvictionPolicy, HostDistance, AddressTranslator, TokenAwarePolicy, HostFilterPolicy) from cassandra import ConsistencyLevel @@ -43,7 +43,7 @@ from tests.integration import use_cluster, get_server_versions, CASSANDRA_VERSION, \ execute_until_pass, execute_with_long_wait_retry, get_node, MockLoggingHandler, get_unsupported_lower_protocol, \ get_unsupported_upper_protocol, local, CASSANDRA_IP, greaterthanorequalcass30, \ - lessthanorequalcass40, TestCluster, PROTOCOL_VERSION, xfail_scylla, incorrect_test + lessthanorequalcass40, TestCluster, PROTOCOL_VERSION, incorrect_test from tests.integration.util import assert_quiescent_pool_state from tests.util import assertListEqual import sys diff --git a/tests/integration/standard/test_concurrent.py b/tests/integration/standard/test_concurrent.py index 5e6b1ffd59..267869b943 100644 --- a/tests/integration/standard/test_concurrent.py +++ b/tests/integration/standard/test_concurrent.py @@ -19,7 +19,6 @@ ReadFailure, WriteFailure from cassandra.cluster import ExecutionProfile, EXEC_PROFILE_DEFAULT from cassandra.concurrent import execute_concurrent, execute_concurrent_with_args, ExecutionResult -from cassandra.policies import HostDistance from cassandra.query import dict_factory, tuple_factory, SimpleStatement from tests.integration import use_singledc, PROTOCOL_VERSION, TestCluster diff --git a/tests/integration/standard/test_concurrent_schema_change_and_node_kill.py b/tests/integration/standard/test_concurrent_schema_change_and_node_kill.py index 9a9a3d325f..87b75144d8 100644 --- a/tests/integration/standard/test_concurrent_schema_change_and_node_kill.py +++ b/tests/integration/standard/test_concurrent_schema_change_and_node_kill.py @@ -1,4 +1,3 @@ -import os import logging import unittest diff --git a/tests/integration/standard/test_custom_cluster.py b/tests/integration/standard/test_custom_cluster.py index 4eb62e43bc..db6eef0be4 100644 --- a/tests/integration/standard/test_custom_cluster.py +++ b/tests/integration/standard/test_custom_cluster.py @@ -14,7 +14,7 @@ from cassandra.cluster import NoHostAvailable from tests.integration import use_singledc, get_cluster, remove_cluster, local, TestCluster -from tests.util import wait_until, wait_until_not_raised +from tests.util import wait_until import unittest import pytest diff --git a/tests/integration/standard/test_custom_payload.py b/tests/integration/standard/test_custom_payload.py index fc58081070..2179c4225d 100644 --- a/tests/integration/standard/test_custom_payload.py +++ b/tests/integration/standard/test_custom_payload.py @@ -17,7 +17,7 @@ from cassandra.query import (SimpleStatement, BatchStatement, BatchType) -from tests.integration import (use_singledc, PROTOCOL_VERSION, local, TestCluster, +from tests.integration import (use_singledc, local, TestCluster, requires_custom_payload) import pytest diff --git a/tests/integration/standard/test_custom_protocol_handler.py b/tests/integration/standard/test_custom_protocol_handler.py index e7d336014f..59283a3b33 100644 --- a/tests/integration/standard/test_custom_protocol_handler.py +++ b/tests/integration/standard/test_custom_protocol_handler.py @@ -16,19 +16,16 @@ from cassandra.protocol import ProtocolHandler, ResultMessage, QueryMessage, UUIDType, read_int from cassandra.query import tuple_factory, SimpleStatement -from cassandra.cluster import (ResponseFuture, ExecutionProfile, EXEC_PROFILE_DEFAULT, - ContinuousPagingOptions, NoHostAvailable) +from cassandra.cluster import (ResponseFuture, ExecutionProfile, EXEC_PROFILE_DEFAULT) from cassandra import ProtocolVersion, ConsistencyLevel from tests.integration import use_single_node, drop_keyspace_shutdown_cluster, \ - greaterthanorequalcass30, execute_with_long_wait_retry, greaterthanorequalcass3_10, \ - TestCluster, greaterthanorequalcass40 + greaterthanorequalcass30, execute_with_long_wait_retry, TestCluster, greaterthanorequalcass40 from tests.integration.datatype_utils import update_datatypes, PRIMITIVE_DATATYPES from tests.integration.standard.utils import create_table_with_all_types, get_all_primitive_params import uuid from unittest import mock -import pytest def setup_module(): diff --git a/tests/integration/standard/test_metadata.py b/tests/integration/standard/test_metadata.py index f5a11dd5fe..562f457a32 100644 --- a/tests/integration/standard/test_metadata.py +++ b/tests/integration/standard/test_metadata.py @@ -14,38 +14,31 @@ import unittest -from collections import defaultdict -import difflib import logging import sys import time import os -from typing import Optional from packaging.version import Version from unittest.mock import Mock, patch import pytest from cassandra import AlreadyExists, SignatureDescriptor, UserFunctionDescriptor, UserAggregateDescriptor -from cassandra.connection import Connection from cassandra.encoder import Encoder from cassandra.metadata import (IndexMetadata, Token, murmur3, Function, Aggregate, protect_name, protect_names, RegisteredTableExtension, _RegisteredExtensionType, get_schema_parser, group_keys_by_replica, NO_VALID_REPLICA) from cassandra.protocol import QueryMessage, ProtocolHandler -from cassandra.util import SortedSet from tests.integration import (get_cluster, use_singledc, PROTOCOL_VERSION, execute_until_pass, BasicSegregatedKeyspaceUnitTestCase, BasicSharedKeyspaceUnitTestCase, BasicExistingKeyspaceUnitTestCase, drop_keyspace_shutdown_cluster, CASSANDRA_VERSION, greaterthanorequalcass30, lessthancass30, local, get_supported_protocol_versions, greaterthancass20, - greaterthancass21, greaterthanorequalcass40, - lessthancass40, + greaterthancass21, lessthancass40, TestCluster, requires_java_udf, requires_composite_type, - requires_collection_indexes, SCYLLA_VERSION, xfail_scylla, xfail_scylla_version_lt, - requirescompactstorage, get_tablets_disabled_ddl_suffix, execute_with_long_wait_retry) + requires_collection_indexes, SCYLLA_VERSION, xfail_scylla, requirescompactstorage, get_tablets_disabled_ddl_suffix, execute_with_long_wait_retry) from tests.util import wait_until, assertRegex, assertDictEqual, assertListEqual, assert_startswith_diff diff --git a/tests/integration/standard/test_policies.py b/tests/integration/standard/test_policies.py index 50b431e3c9..c67eb1cf3a 100644 --- a/tests/integration/standard/test_policies.py +++ b/tests/integration/standard/test_policies.py @@ -15,9 +15,7 @@ import unittest from cassandra.cluster import ExecutionProfile, EXEC_PROFILE_DEFAULT -from cassandra.policies import HostFilterPolicy, RoundRobinPolicy, SimpleConvictionPolicy, \ - WhiteListRoundRobinPolicy, ExponentialBackoffRetryPolicy, ColDesc -from cassandra.pool import Host +from cassandra.policies import HostFilterPolicy, RoundRobinPolicy, WhiteListRoundRobinPolicy, ExponentialBackoffRetryPolicy from cassandra.connection import DefaultEndPoint from tests.integration import local, use_singledc, TestCluster diff --git a/tests/integration/standard/test_query.py b/tests/integration/standard/test_query.py index 210f6dacb1..9f43b0e61a 100644 --- a/tests/integration/standard/test_query.py +++ b/tests/integration/standard/test_query.py @@ -23,7 +23,7 @@ from cassandra.query import (PreparedStatement, BoundStatement, SimpleStatement, BatchStatement, BatchType, dict_factory, TraceUnavailable) from cassandra.cluster import NoHostAvailable, ExecutionProfile, EXEC_PROFILE_DEFAULT, Cluster -from cassandra.policies import HostDistance, RoundRobinPolicy, WhiteListRoundRobinPolicy +from cassandra.policies import RoundRobinPolicy, WhiteListRoundRobinPolicy from tests.integration import use_singledc, PROTOCOL_VERSION, BasicSharedKeyspaceUnitTestCase, \ greaterthanprotocolv3, MockLoggingHandler, get_supported_protocol_versions, local, get_cluster, setup_keyspace, \ USE_CASS_EXTERNAL, greaterthanorequalcass40, TestCluster, xfail_scylla, xfail_scylla_version_lt, \ diff --git a/tests/integration/standard/test_query_paging.py b/tests/integration/standard/test_query_paging.py index e0c67cd309..0dca7ffd41 100644 --- a/tests/integration/standard/test_query_paging.py +++ b/tests/integration/standard/test_query_paging.py @@ -25,7 +25,6 @@ from cassandra import ConsistencyLevel from cassandra.cluster import EXEC_PROFILE_DEFAULT, ExecutionProfile from cassandra.concurrent import execute_concurrent, execute_concurrent_with_args -from cassandra.policies import HostDistance from cassandra.query import SimpleStatement from tests.util import assertSequenceEqual diff --git a/tests/integration/standard/test_shard_aware.py b/tests/integration/standard/test_shard_aware.py index 4a6c7887d8..6daba6e26f 100644 --- a/tests/integration/standard/test_shard_aware.py +++ b/tests/integration/standard/test_shard_aware.py @@ -23,7 +23,7 @@ from cassandra.cluster import Cluster from cassandra.policies import TokenAwarePolicy, RoundRobinPolicy, ConstantReconnectionPolicy -from cassandra import OperationTimedOut, ConsistencyLevel +from cassandra import OperationTimedOut from tests.integration import use_cluster, get_node, PROTOCOL_VERSION from tests.util import wait_until_not_raised diff --git a/tests/integration/standard/test_single_interface.py b/tests/integration/standard/test_single_interface.py index 5fd9ef45d3..ad31821a7e 100644 --- a/tests/integration/standard/test_single_interface.py +++ b/tests/integration/standard/test_single_interface.py @@ -13,14 +13,12 @@ # limitations under the License. import unittest -import pytest from cassandra import ConsistencyLevel from cassandra.query import SimpleStatement from packaging.version import Version -from tests.integration import use_singledc, PROTOCOL_VERSION, \ - remove_cluster, greaterthanorequalcass40, \ +from tests.integration import use_singledc, remove_cluster, greaterthanorequalcass40, \ CASSANDRA_VERSION, TestCluster, DEFAULT_SINGLE_INTERFACE_PORT diff --git a/tests/integration/standard/test_types.py b/tests/integration/standard/test_types.py index d742f84ffb..cc946bf0d5 100644 --- a/tests/integration/standard/test_types.py +++ b/tests/integration/standard/test_types.py @@ -21,15 +21,13 @@ import socket import uuid -from datetime import datetime, date, time, timedelta +from datetime import datetime, timedelta from decimal import Decimal from functools import partial -from packaging.version import Version import cassandra from cassandra import InvalidRequest -from cassandra import util from cassandra.cluster import ExecutionProfile, EXEC_PROFILE_DEFAULT from cassandra.concurrent import execute_concurrent_with_args from cassandra.cqltypes import Int32Type, EMPTY diff --git a/tests/integration/upgrade/__init__.py b/tests/integration/upgrade/__init__.py index fab6fed34a..42588f1608 100644 --- a/tests/integration/upgrade/__init__.py +++ b/tests/integration/upgrade/__init__.py @@ -13,7 +13,7 @@ # limitations under the License. -from tests.integration import CCM_KWARGS, use_cluster, remove_cluster, MockLoggingHandler +from tests.integration import use_cluster, remove_cluster, MockLoggingHandler from tests.integration import setup_keyspace from cassandra.cluster import Cluster diff --git a/tests/integration/upgrade/test_upgrade.py b/tests/integration/upgrade/test_upgrade.py index 45827723b3..1eccf12712 100644 --- a/tests/integration/upgrade/test_upgrade.py +++ b/tests/integration/upgrade/test_upgrade.py @@ -21,7 +21,6 @@ from tests.integration.upgrade import UpgradeBase, UpgradeBaseAuth, UpgradePath, upgrade_paths from tests.util import wait_until -import unittest import pytest diff --git a/tests/stress_tests/test_load.py b/tests/stress_tests/test_load.py index 3492ff2923..7bf7a2d374 100644 --- a/tests/stress_tests/test_load.py +++ b/tests/stress_tests/test_load.py @@ -11,7 +11,6 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -import unittest import gc diff --git a/tests/unit/advanced/test_graph.py b/tests/unit/advanced/test_graph.py index 5b82def245..6939f1217c 100644 --- a/tests/unit/advanced/test_graph.py +++ b/tests/unit/advanced/test_graph.py @@ -19,7 +19,7 @@ from cassandra import ConsistencyLevel from cassandra.policies import RetryPolicy -from cassandra.graph import (SimpleGraphStatement, GraphOptions, GraphProtocol, Result, +from cassandra.graph import (SimpleGraphStatement, GraphOptions, Result, graph_result_row_factory, single_object_row_factory, Vertex, Edge, Path, VertexProperty) from cassandra.datastax.graph.query import _graph_options diff --git a/tests/unit/advanced/test_insights.py b/tests/unit/advanced/test_insights.py index ec9b918866..2050439804 100644 --- a/tests/unit/advanced/test_insights.py +++ b/tests/unit/advanced/test_insights.py @@ -21,18 +21,12 @@ from cassandra import ConsistencyLevel from cassandra.cluster import ( - ExecutionProfile, GraphExecutionProfile, ProfileManager, - GraphAnalyticsExecutionProfile, - EXEC_PROFILE_DEFAULT, EXEC_PROFILE_GRAPH_DEFAULT, - EXEC_PROFILE_GRAPH_ANALYTICS_DEFAULT, - EXEC_PROFILE_GRAPH_SYSTEM_DEFAULT + ExecutionProfile, GraphExecutionProfile, GraphAnalyticsExecutionProfile ) from cassandra.datastax.graph.query import GraphOptions from cassandra.datastax.insights.registry import insights_registry from cassandra.datastax.insights.serializers import initialize_registry -from cassandra.datastax.insights.util import namespace from cassandra.policies import ( - RoundRobinPolicy, LoadBalancingPolicy, DCAwareRoundRobinPolicy, TokenAwarePolicy, diff --git a/tests/unit/advanced/test_metadata.py b/tests/unit/advanced/test_metadata.py index d68a87961d..1503759372 100644 --- a/tests/unit/advanced/test_metadata.py +++ b/tests/unit/advanced/test_metadata.py @@ -16,7 +16,7 @@ from cassandra.metadata import ( KeyspaceMetadata, TableMetadataDSE68, - VertexMetadata, EdgeMetadata, SchemaParserV22, _SchemaParser + VertexMetadata, EdgeMetadata, _SchemaParser ) from cassandra.protocol import ResultMessage, RESULT_KIND_ROWS diff --git a/tests/unit/io/test_asyncorereactor.py b/tests/unit/io/test_asyncorereactor.py index d614a856d1..c8f979bdb9 100644 --- a/tests/unit/io/test_asyncorereactor.py +++ b/tests/unit/io/test_asyncorereactor.py @@ -11,7 +11,6 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -import platform import unittest from unittest.mock import patch diff --git a/tests/unit/io/test_twistedreactor.py b/tests/unit/io/test_twistedreactor.py index 8ba9ca5b1d..02bac10d8e 100644 --- a/tests/unit/io/test_twistedreactor.py +++ b/tests/unit/io/test_twistedreactor.py @@ -19,7 +19,6 @@ try: from twisted.test import proto_helpers - from twisted.python.failure import Failure from cassandra.io import twistedreactor from cassandra.io.twistedreactor import TwistedConnection except ImportError: diff --git a/tests/unit/test_endpoints.py b/tests/unit/test_endpoints.py index 14fb8b5806..1b6367dc2d 100644 --- a/tests/unit/test_endpoints.py +++ b/tests/unit/test_endpoints.py @@ -10,7 +10,7 @@ import itertools -from cassandra.connection import DefaultEndPoint, SniEndPoint, SniEndPointFactory +from cassandra.connection import DefaultEndPoint, SniEndPointFactory from unittest.mock import patch diff --git a/tests/unit/test_host_connection_pool.py b/tests/unit/test_host_connection_pool.py index f92bb53785..8bb57d0dc0 100644 --- a/tests/unit/test_host_connection_pool.py +++ b/tests/unit/test_host_connection_pool.py @@ -13,7 +13,6 @@ # limitations under the License. from concurrent.futures import ThreadPoolExecutor import logging -import time import uuid from cassandra.protocol_features import ProtocolFeatures diff --git a/tests/unit/test_marshalling.py b/tests/unit/test_marshalling.py index e4b415ac69..02ca901abc 100644 --- a/tests/unit/test_marshalling.py +++ b/tests/unit/test_marshalling.py @@ -11,7 +11,6 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -import sys from cassandra import ProtocolVersion diff --git a/tests/unit/test_protocol.py b/tests/unit/test_protocol.py index 9704811239..da47f3f08c 100644 --- a/tests/unit/test_protocol.py +++ b/tests/unit/test_protocol.py @@ -19,13 +19,9 @@ from cassandra import ProtocolVersion, UnsupportedOperation from cassandra.protocol import ( PrepareMessage, QueryMessage, ExecuteMessage, UnsupportedOperation, - _PAGING_OPTIONS_FLAG, _WITH_SERIAL_CONSISTENCY_FLAG, - _PAGE_SIZE_FLAG, _WITH_PAGING_STATE_FLAG, BatchMessage ) from cassandra.query import BatchType -from cassandra.marshal import uint32_unpack -from cassandra.cluster import ContinuousPagingOptions import pytest From 6471abe8b553592c76fd8e945c43054ccef156a6 Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Mon, 29 Jun 2026 10:36:12 +0300 Subject: [PATCH 091/200] CI: skip 32-bit Windows wheel builds The *i686 skip pattern in cibuildwheel config matches only Linux 32-bit identifiers (e.g. cp310-manylinux_i686), not Windows 32-bit (which uses win32, e.g. cp310-win32). Add *win32 to skip 32-bit Windows builds, which fail because: - c_shard_info.c uses __uint128_t (GCC extension, unsupported by MSVC) - cryptography test dependency fails to build for i686-pc-windows-msvc due to missing OpenSSL --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 4a40af5378..c5ff52a426 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -142,6 +142,7 @@ skip = [ "cp3*t-*", "pp3*t-*", "*i686", + "*win32", "*musllinux*", ] build = ["cp3*", "pp3*"] From 03c4c9601580bf433217b54d39d1e804ad3e959a Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Mon, 29 Jun 2026 23:32:46 +0300 Subject: [PATCH 092/200] fix: add missing scope validation in Session.wait_for_schema_agreement The docstring and test promised ValueError for invalid scope values (e.g. 'planet'), but the validation was never implemented in the method body. Add an explicit check against the three SchemaAgreementScope members. Introduced in commit 0d215f45b (cluster: add Session.wait_for_schema_agreement). --- cassandra/cluster.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 6a8a6350e6..c6b018e3b2 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -3481,6 +3481,11 @@ def wait_for_schema_agreement(self, wait_time: Optional[float] = None, if wait_time is not None and wait_time <= 0: raise ValueError("wait_time must be greater than 0") + if scope not in (SchemaAgreementScope.RACK, SchemaAgreementScope.DC, SchemaAgreementScope.CLUSTER): + raise ValueError( + "scope must be SchemaAgreementScope.RACK, .DC, or .CLUSTER" + ) + total_timeout = wait_time if wait_time is not None else self.cluster.max_schema_agreement_wait if total_timeout <= 0: raise ValueError("total_timeout must be greater than 0") From 8c0688081fdb91db857fa07e134f6cac0841c75c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 00:14:14 +0000 Subject: [PATCH 093/200] chore(deps): update dependency tornado to v6.5.7 [security] --- docs/uv.lock | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/uv.lock b/docs/uv.lock index 19962f649f..604064a5dc 100644 --- a/docs/uv.lock +++ b/docs/uv.lock @@ -1029,19 +1029,19 @@ wheels = [ [[package]] name = "tornado" -version = "6.5.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/f1/3173dfa4a18db4a9b03e5d55325559dab51ee653763bb8745a75af491286/tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9", size = 516006, upload-time = "2026-03-10T21:31:02.067Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/59/8c/77f5097695f4dd8255ecbd08b2a1ed8ba8b953d337804dd7080f199e12bf/tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa", size = 445983, upload-time = "2026-03-10T21:30:44.28Z" }, - { url = "https://files.pythonhosted.org/packages/ab/5e/7625b76cd10f98f1516c36ce0346de62061156352353ef2da44e5c21523c/tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521", size = 444246, upload-time = "2026-03-10T21:30:46.571Z" }, - { url = "https://files.pythonhosted.org/packages/b2/04/7b5705d5b3c0fab088f434f9c83edac1573830ca49ccf29fb83bf7178eec/tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5", size = 447229, upload-time = "2026-03-10T21:30:48.273Z" }, - { url = "https://files.pythonhosted.org/packages/34/01/74e034a30ef59afb4097ef8659515e96a39d910b712a89af76f5e4e1f93c/tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07", size = 448192, upload-time = "2026-03-10T21:30:51.22Z" }, - { url = "https://files.pythonhosted.org/packages/be/00/fe9e02c5a96429fce1a1d15a517f5d8444f9c412e0bb9eadfbe3b0fc55bf/tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e", size = 448039, upload-time = "2026-03-10T21:30:53.52Z" }, - { url = "https://files.pythonhosted.org/packages/82/9e/656ee4cec0398b1d18d0f1eb6372c41c6b889722641d84948351ae19556d/tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca", size = 447445, upload-time = "2026-03-10T21:30:55.541Z" }, - { url = "https://files.pythonhosted.org/packages/5a/76/4921c00511f88af86a33de770d64141170f1cfd9c00311aea689949e274e/tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7", size = 448582, upload-time = "2026-03-10T21:30:57.142Z" }, - { url = "https://files.pythonhosted.org/packages/2c/23/f6c6112a04d28eed765e374435fb1a9198f73e1ec4b4024184f21faeb1ad/tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b", size = 448990, upload-time = "2026-03-10T21:30:58.857Z" }, - { url = "https://files.pythonhosted.org/packages/b7/c8/876602cbc96469911f0939f703453c1157b0c826ecb05bdd32e023397d4e/tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6", size = 448016, upload-time = "2026-03-10T21:31:00.43Z" }, +version = "6.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/64/24/95ec527ad67b76d59299e5465b3935d05e4294b7e0290a3924b7487df30b/tornado-6.5.7.tar.gz", hash = "sha256:66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c3ddfc2", size = 519252, upload-time = "2026-06-08T17:34:51.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/dc/c7043cab6fed8ae159fc1923ce829ada35c4dbd797d408a43858ffaf9639/tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163", size = 448543, upload-time = "2026-06-08T17:34:38.052Z" }, + { url = "https://files.pythonhosted.org/packages/92/4f/090b1431e5a43df696feceffc268c5383cc079ecb5f08ce58f917109aafe/tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100", size = 446707, upload-time = "2026-06-08T17:34:39.594Z" }, + { url = "https://files.pythonhosted.org/packages/37/d8/ef374952fd5da67d4463122c2b8e5a96536ec10b4b339254c6dcde81d01c/tornado-6.5.7-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d759e71906ee783f8867b93bf26a265743da4c1e2f4a018464c1ba019862972", size = 449774, upload-time = "2026-06-08T17:34:41.204Z" }, + { url = "https://files.pythonhosted.org/packages/35/37/d434c73f4c6e014b745b9b37085f34f40c022f007efff3d7fe65991899f3/tornado-6.5.7-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a46347a18f23fb92b396beebe0fb78f61dda0cc302445202c16203d8a18848b", size = 450745, upload-time = "2026-06-08T17:34:42.531Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/56b9aff361d7f1ab728a805ec7d7ea835f8807afa9f5cc690ea0e630efb9/tornado-6.5.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7778b30bef919231265e91c69963ce0f49a1e9c07ac900bbe75b19ce2575ba92", size = 450578, upload-time = "2026-06-08T17:34:43.787Z" }, + { url = "https://files.pythonhosted.org/packages/02/30/a7444fb23aa76860a14198fab96ac79f1866b0a6e19e26c4381b0938e50f/tornado-6.5.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e726f0c75da7726eec023aa62751ff8878bd2737e34fbdd33b1ae5897d2200f5", size = 449985, upload-time = "2026-06-08T17:34:45.326Z" }, + { url = "https://files.pythonhosted.org/packages/5c/42/5f0e56c01e8d9d36f4e23f367b85ae6cae0c1ecddd5e6977d8388ad27488/tornado-6.5.7-cp39-abi3-win32.whl", hash = "sha256:f8de3bf12d3efdd0cbe7c8887868198f8a91415e3f29fcf258d9b8eb7b1d9ae4", size = 451047, upload-time = "2026-06-08T17:34:46.784Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl", hash = "sha256:de942f843533a039ef9fa3d9c88c7cd8a7c94553fb5ad0154270989b3d99a2c4", size = 451485, upload-time = "2026-06-08T17:34:48.248Z" }, + { url = "https://files.pythonhosted.org/packages/71/2e/7b1c769803121b809112cf9a00681c472eae1d80e32d7ec0e0bd61d0d0e1/tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796", size = 450506, upload-time = "2026-06-08T17:34:49.702Z" }, ] [[package]] From 0fa8f1ab425f1042084e67b3d18fce664e4c61dd Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Mon, 29 Jun 2026 23:03:11 +0300 Subject: [PATCH 094/200] build(deps): bump aiohttp from 3.13.5 to 3.14.1 in /docs Resolves Dependabot alert #67 (GHSA-w2fm-2cpv-w7v5 / CVE-2026-22815): aiohttp <= 3.13.3 allows unlimited trailer headers, leading to possible uncapped memory usage (CWE-400/CWE-770). Fixed in aiohttp 3.13.4. aiohttp is a transitive runtime dependency pulled in only by the docs toolchain via gremlinpython==3.7.4. Bumped to 3.14.1 (>= 3.13.4 patched). --- docs/uv.lock | 46 ++++++++++++++++++++++++++-------------------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/docs/uv.lock b/docs/uv.lock index 604064a5dc..3223c9469c 100644 --- a/docs/uv.lock +++ b/docs/uv.lock @@ -22,7 +22,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.5" +version = "3.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -33,25 +33,31 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" }, - { url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" }, - { url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" }, - { url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" }, - { url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" }, - { url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" }, - { url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" }, - { url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" }, - { url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" }, - { url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" }, - { url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" }, - { url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" }, - { url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" }, - { url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" }, - { url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" }, + { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" }, + { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" }, + { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" }, + { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" }, + { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" }, + { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" }, + { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" }, + { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" }, + { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, ] [[package]] From ad8636e866cc04ab5c601f6fe3f5a8e988700518 Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Wed, 13 May 2026 19:56:23 +0300 Subject: [PATCH 095/200] policies: treat SERIAL/LOCAL_SERIAL consistency as LWT for routing Statements with SERIAL or LOCAL_SERIAL consistency level are serialized through the Paxos path on the server, but TokenAwarePolicy only checked is_lwt() (from server prepare metadata) when deciding whether to skip replica shuffling. This meant serial-consistency reads could be routed with shuffled replicas instead of the deterministic order needed for optimal Paxos coordination. Now TokenAwarePolicy also checks the statement's consistency level and skips shuffling for SERIAL/LOCAL_SERIAL, matching LWT routing behavior. Fixes: https://github.com/scylladb/python-driver/issues/886 --- cassandra/policies.py | 2 +- tests/unit/test_policies.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/cassandra/policies.py b/cassandra/policies.py index ceb5ebdc45..14c79fd70e 100644 --- a/cassandra/policies.py +++ b/cassandra/policies.py @@ -514,7 +514,7 @@ def make_query_plan(self, working_keyspace=None, query=None): else: replicas = self._cluster_metadata.get_replicas(keyspace, query.routing_key) - if self.shuffle_replicas and not query.is_lwt(): + if self.shuffle_replicas and not query.is_lwt() and not ConsistencyLevel.is_serial(query.consistency_level): shuffle(replicas) def yield_in_order(hosts): diff --git a/tests/unit/test_policies.py b/tests/unit/test_policies.py index 6142af1aa1..41bd42481c 100644 --- a/tests/unit/test_policies.py +++ b/tests/unit/test_policies.py @@ -944,6 +944,35 @@ def _assert_shuffle(self, patched_shuffle, cluster, keyspace, routing_key): assert patched_shuffle.call_count == 1 + @patch('cassandra.policies.shuffle') + def test_no_shuffle_for_serial_consistency(self, patched_shuffle): + """ + Test to validate that replicas are not shuffled when the statement + has SERIAL or LOCAL_SERIAL consistency level, since such statements + should be routed like LWT requests. + @jira_ticket PYTHON-1394 + @expected_result shuffle should not be called for serial consistency + + @test_category policy + """ + for cl in (ConsistencyLevel.SERIAL, ConsistencyLevel.LOCAL_SERIAL): + for cluster in (self._prepare_cluster_with_vnodes(), self._prepare_cluster_with_tablets()): + patched_shuffle.reset_mock() + hosts = cluster.metadata.all_hosts() + child_policy = Mock() + child_policy.make_query_plan.return_value = hosts + child_policy.distance.return_value = HostDistance.LOCAL + + policy = TokenAwarePolicy(child_policy, shuffle_replicas=True) + policy.populate(cluster, hosts) + + query = Statement(routing_key='routing_key') + query.consistency_level = cl + list(policy.make_query_plan('keyspace', query)) + assert patched_shuffle.call_count == 0, \ + "shuffle should not be called for consistency level %s" % cl + + class ConvictionPolicyTest(unittest.TestCase): def test_not_implemented(self): """ From ca42a5478eade603d4b38208423d36de56b1a347 Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Wed, 20 May 2026 15:56:51 +0300 Subject: [PATCH 096/200] policies: prevent retry downgrade from serial to non-serial consistency Add a guard in the retry execution path that prevents any retry policy from downgrading SERIAL/LOCAL_SERIAL to a non-serial consistency level, which would break serial read (Paxos) guarantees. Also add a unit test verifying DowngradingConsistencyRetryPolicy does not downgrade serial consistency on read timeout or unavailable. Fixes: https://scylladb.atlassian.net/browse/DRIVER-613 --- cassandra/cluster.py | 12 +++++++++++- tests/unit/test_policies.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index c6b018e3b2..57a8ef10aa 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -5437,7 +5437,17 @@ def _retry(self, reuse_connection, consistency_level, host, delay): if self._metrics is not None: self._metrics.on_retry() if consistency_level is not None: - self.message.consistency_level = consistency_level + # Never downgrade from serial to non-serial consistency, as that + # would break serial read (Paxos) guarantees. + original_cl = self.message.consistency_level + if ConsistencyLevel.is_serial(original_cl) and not ConsistencyLevel.is_serial(consistency_level): + log.debug( + "Retry policy attempted to downgrade serial consistency %s to %s; " + "keeping original consistency level.", + ConsistencyLevel.value_to_name.get(original_cl, original_cl), + ConsistencyLevel.value_to_name.get(consistency_level, consistency_level)) + else: + self.message.consistency_level = consistency_level # don't retry on the event loop thread self.session.cluster.scheduler.schedule(delay, self._retry_task, reuse_connection, host) diff --git a/tests/unit/test_policies.py b/tests/unit/test_policies.py index 41bd42481c..63a3c3d12d 100644 --- a/tests/unit/test_policies.py +++ b/tests/unit/test_policies.py @@ -1418,6 +1418,35 @@ def test_unavailable(self): assert retry == RetryPolicy.RETRY assert consistency == ConsistencyLevel.ONE + def test_serial_consistency_not_downgraded(self): + """ + Test that SERIAL/LOCAL_SERIAL consistency is never downgraded + to a non-serial consistency level by the retry policy. + @jira_ticket PYTHON-1394 + @expected_result retry policy should rethrow or retry on next host + without downgrading serial consistency + + @test_category policy + """ + policy = DowngradingConsistencyRetryPolicy() + + for cl in (ConsistencyLevel.SERIAL, ConsistencyLevel.LOCAL_SERIAL): + # on_read_timeout should rethrow for serial consistency + retry, consistency = policy.on_read_timeout( + query=None, consistency=cl, required_responses=3, + received_responses=1, data_retrieved=True, retry_num=0) + assert retry == RetryPolicy.RETHROW, \ + "Expected RETHROW for serial consistency %s on read timeout" % cl + assert consistency is None + + # on_unavailable should retry on next host without downgrading + retry, consistency = policy.on_unavailable( + query=None, consistency=cl, required_replicas=3, + alive_replicas=1, retry_num=0) + assert retry == RetryPolicy.RETRY_NEXT_HOST, \ + "Expected RETRY_NEXT_HOST for serial consistency %s on unavailable" % cl + assert consistency is None + class ExponentialRetryPolicyTest(unittest.TestCase): def test_calculate_backoff(self): From 4753428ef5d03b36e5cba8a924ae5105e4d900a5 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Thu, 18 Jun 2026 11:46:41 +0200 Subject: [PATCH 097/200] test_libevreactor: Restore preparer after cleanup test_watchers_are_finished calls libev__cleanup(), which stops the shared libev loop preparer. If a timer test runs later, the stopped preparer means timers are never scheduled and the test can hang. Restore _global_loop._shutdown and restart _global_loop._preparer after the cleanup path runs. Put the restoration in a finally block so the shared loop is left usable even if the post-cleanup watcher assertions fail. --- tests/unit/io/test_libevreactor.py | 45 ++++++++++++++++++------------ 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/tests/unit/io/test_libevreactor.py b/tests/unit/io/test_libevreactor.py index cf7e7caf77..a228a71de8 100644 --- a/tests/unit/io/test_libevreactor.py +++ b/tests/unit/io/test_libevreactor.py @@ -69,24 +69,33 @@ def test_watchers_are_finished(self): @test_category connection """ from cassandra.io.libevreactor import _global_loop - with patch.object(_global_loop, "_thread"),\ - patch.object(_global_loop, "notify"): - - self.make_connection() - - # We have to make a copy because the connections shouldn't - # be alive when we verify them - live_connections = set(_global_loop._live_conns) - - # This simulates the process ending without cluster.shutdown() - # being called, then with atexit _cleanup for libevreactor would - # be called - libev__cleanup(_global_loop) - for conn in live_connections: - assert conn._write_watcher.stop.mock_calls - assert conn._read_watcher.stop.mock_calls - - _global_loop._shutdown = False + reactor_needs_restore = False + try: + with patch.object(_global_loop, "_thread"),\ + patch.object(_global_loop, "notify"): + + self.make_connection() + + # We have to make a copy because the connections shouldn't + # be alive when we verify them + live_connections = set(_global_loop._live_conns) + + # This simulates the process ending without cluster.shutdown() + # being called, then with atexit _cleanup for libevreactor would + # be called + reactor_needs_restore = True + libev__cleanup(_global_loop) + for conn in live_connections: + assert conn._write_watcher.stop.mock_calls + assert conn._read_watcher.stop.mock_calls + + finally: + if reactor_needs_restore: + _global_loop._shutdown = False + # _cleanup stopped the prepare watcher; restart it so the shared + # singleton loop is left in a working state for subsequent tests + # (otherwise timers would never be scheduled and tests would hang). + _global_loop._preparer.start() class LibevTimerPatcher(unittest.TestCase): From 721eacf71838f274480cb217cc0d5b503a61bdcb Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Thu, 18 Jun 2026 11:47:39 +0200 Subject: [PATCH 098/200] pyproject.toml: Add setuptools to dev group Python 3.12 removed distutils from the standard library. Some Cython test helpers import pyximport, which still imports distutils through setuptools' compatibility shim during collection. Add setuptools to the dev test dependencies so those tests can collect in cibuildwheel's test environment. --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index c5ff52a426..8cffa137f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,7 @@ dev = [ "gevent", "eventlet>=0.33.3", "cython>=3.2", + "setuptools", "packaging>=25.0", "futurist", "pyyaml", From 5694c85b14bd64795e05d40207e619a0ef316fd3 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Thu, 18 Jun 2026 12:04:38 +0200 Subject: [PATCH 099/200] CI: turn silent unit-test skips into failures Tests skip themselves when their requirements are missing (a library is absent, the wrong event loop is selected, the C extensions aren't built). That is convenient locally but a footgun in CI, where a test may be silently skipped because a dependency was not installed. The libev unit tests were effectively not running in any CI configuration. Add a CASS_DRIVER_NO_SKIP-gated pytest hook in tests/conftest.py that turns skips into failures (xfail untouched), and enable it in the cibuildwheel test-commands where the C extensions are mandatory (Linux/macOS). Tests that genuinely cannot run in the default configuration are listed explicitly via -k/--ignore (reactor tests run separately per EVENT_LOOP_MANAGER; asyncore, column_encryption and a few upstream-disabled/flaky tests excluded). Add -v to every pytest invocation and install the compress-lz4 extra so the lz4 tests actually run. Pass --import-mode=append in the cibuildwheel pytest commands so the installed compiled wheel takes precedence on sys.path over the in-tree pure-Python cassandra source during wheel tests. Keep the global pytest addopts unchanged so local pytest runs keep their normal import behavior. Windows and PyPy keep no-skip off: the extensions are optional on Windows and are never built on PyPy (setup.py forces is_pypy to skip libev/cmurmur3/Cython), so their extension-dependent skips are legitimate. The PyPy override also drops the compress-lz4 extra (no prebuilt PyPy lz4 wheel), uses cross-shell quoting, and deselects the known PyPy/Windows/macOS-incompatible tests. --- .github/workflows/integration-tests.yml | 2 +- pyproject.toml | 57 +++++++++++++++++++++++-- tests/conftest.py | 36 ++++++++++++++++ 3 files changed, 90 insertions(+), 5 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 5e76d6bbb4..acebb1d617 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -98,4 +98,4 @@ jobs: if [[ "${{ matrix.python-version }}" =~ t$ ]]; then export PYTHON_GIL=0 fi - uv run pytest tests/integration/standard/ tests/integration/cqlengine/ + uv run pytest -v tests/integration/standard/ tests/integration/cqlengine/ diff --git a/pyproject.toml b/pyproject.toml index 8cffa137f0..698ff4c37b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -158,22 +158,71 @@ enable = ["pypy"] [tool.cibuildwheel.linux] before-build = "rm -rf ~/.pyxbld && rpm --import https://repo.almalinux.org/almalinux/RPM-GPG-KEY-AlmaLinux && yum install -y libffi-devel libev libev-devel openssl openssl-devel" +# Install the optional lz4 compression dependency so the lz4 segment tests run +# (and fail loudly under CASS_DRIVER_NO_SKIP) instead of skipping silently. +test-extras = ["compress-lz4"] +# Extensions are mandatory on Linux (CASS_DRIVER_BUILD_EXTENSIONS_ARE_MUST=yes), +# so skipping is disabled (CASS_DRIVER_NO_SKIP=1): a missing dependency such as +# libev fails loudly instead of being silently skipped. Tests that cannot run in +# the default configuration are listed explicitly: +# * event-loop reactor tests are run separately with the matching +# EVENT_LOOP_MANAGER (gevent/eventlet/asyncio); +# * asyncore is deprecated and unavailable on modern Python, so it is ignored; +# * column_encryption is disabled upstream (scylladb/python-driver#365); +# * test_deserialize_date_range_month is disabled upstream (PYTHON-912). +# PyPy uses the pp* override below. All Linux CPython reactor commands run with +# CASS_DRIVER_NO_SKIP=1 so unexpected skips fail loudly. test-command = [ - "pytest {package}/tests/unit", - "EVENT_LOOP_MANAGER=gevent pytest {package}/tests/unit/io/test_geventreactor.py", + "CASS_DRIVER_NO_SKIP=1 pytest --import-mode=append {package}/tests/unit -v --ignore={package}/tests/unit/column_encryption --ignore={package}/tests/unit/io/test_geventreactor.py --ignore={package}/tests/unit/io/test_eventletreactor.py --ignore={package}/tests/unit/io/test_asyncioreactor.py --ignore={package}/tests/unit/io/test_asyncorereactor.py -k 'not test_deserialize_date_range_month'", + "EVENT_LOOP_MANAGER=gevent CASS_DRIVER_NO_SKIP=1 pytest --import-mode=append {package}/tests/unit/io/test_geventreactor.py -v", + "EVENT_LOOP_MANAGER=asyncio CASS_DRIVER_NO_SKIP=1 pytest --import-mode=append {package}/tests/unit/io/test_asyncioreactor.py -v", + "EVENT_LOOP_MANAGER=eventlet CASS_DRIVER_NO_SKIP=1 pytest --import-mode=append {package}/tests/unit/io/test_eventletreactor.py -v", ] [tool.cibuildwheel.macos] build-frontend = "build" +# Install lz4 so the lz4 segment tests run instead of skipping (see Linux note). +test-extras = ["compress-lz4"] +# Same policy as Linux (extensions are mandatory here too, libev comes from +# Homebrew). The extra -k exclusions are timing-sensitive tests that are flaky +# on macOS runners. The gevent/eventlet/asyncio reactor test files only contain +# those timing-sensitive timer tests, so they are not run separately here. test-command = [ - "pytest {project}/tests/unit -k 'not (test_multi_timer_validation or test_empty_connections or test_timer_cancellation)'", + "CASS_DRIVER_NO_SKIP=1 pytest --import-mode=append {project}/tests/unit -v --ignore={project}/tests/unit/column_encryption --ignore={project}/tests/unit/io/test_geventreactor.py --ignore={project}/tests/unit/io/test_eventletreactor.py --ignore={project}/tests/unit/io/test_asyncioreactor.py --ignore={project}/tests/unit/io/test_asyncorereactor.py -k 'not (test_multi_timer_validation or test_empty_connections or test_timer_cancellation or test_deserialize_date_range_month)'", ] [tool.cibuildwheel.windows] build-frontend = "build" +# On Windows the C extensions are optional (CASS_DRIVER_BUILD_EXTENSIONS_ARE_MUST +# is overridden to "no" below), so extension-dependent tests (e.g. libev) are +# legitimately skipped here. CASS_DRIVER_NO_SKIP is therefore NOT enabled on +# Windows; we only add -v so skips are visible in the log. test-command = [ - "pytest {project}/tests/unit -k \"not (test_deserialize_date_range_year or test_datetype or test_libevreactor)\"", + "pytest --import-mode=append {project}/tests/unit -v -k \"not (test_deserialize_date_range_year or test_datetype or test_libevreactor)\"", ] # TODO: set CASS_DRIVER_BUILD_EXTENSIONS_ARE_MUST to yes when https://github.com/scylladb/python-driver/issues/429 is fixed environment = { CASS_DRIVER_BUILD_CONCURRENCY = "2", CASS_DRIVER_BUILD_EXTENSIONS_ARE_MUST = "no" } + +# PyPy never builds the libev/cmurmur3/Cython C extensions (setup.py forces +# is_pypy to skip them even when CASS_DRIVER_BUILD_EXTENSIONS_ARE_MUST=yes), so +# the tests that depend on those extensions legitimately skip. Enforcing +# CASS_DRIVER_NO_SKIP would turn those expected skips into failures, so it is +# NOT enabled for PyPy (same reasoning as Windows). The reactor tests are not +# run separately here because eventlet is unsupported on PyPy (@notpypy) and the +# extension-backed reactors are unavailable; with no-skip off they simply skip. +# test-extras is cleared (no compress-lz4): PyPy has no prebuilt lz4 wheel, so +# pip would try to compile it from source and fail. The lz4 tests just skip here. +# test_deserialize_date_range_year and test_datetype are excluded because they +# fail on Windows (the C runtime's gmtime rejects the far-future timestamps they +# use); the CPython Windows command excludes them for the same reason. The +# timer tests (test_multi_timer_validation, test_empty_connections, +# test_timer_cancellation) are timing-sensitive and flaky on macOS, matching the +# CPython macOS exclusions. The override matches PyPy on all OSes, so these are +# deselected everywhere here (they are still covered by the CPython runs). +[[tool.cibuildwheel.overrides]] +select = "pp*" +test-extras = [] +test-command = [ + "pytest --import-mode=append {package}/tests/unit -v --ignore={package}/tests/unit/column_encryption -k \"not (test_deserialize_date_range_month or test_deserialize_date_range_year or test_datetype or test_multi_timer_validation or test_empty_connections or test_timer_cancellation)\"", +] diff --git a/tests/conftest.py b/tests/conftest.py index 8fd2fc923b..8eed388549 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,9 +16,45 @@ import os import warnings +import pytest + # Directory containing the Cython-compiled driver modules. _CASSANDRA_DIR = os.path.join(os.path.dirname(__file__), os.pardir, "cassandra") +# When set (e.g. in CI) a skipped test is turned into a failure. Tests skip +# themselves when their requirements are missing (a library is not installed, +# the wrong event loop is selected, ...). That is convenient locally, but in CI +# it is a footgun: a test may be silently skipped because we forgot to install +# something. Enabling this forces every skip to be explicit on the command line +# (via -k / --ignore / --deselect) instead of being hidden in the output. +_NO_SKIP = bool(os.environ.get("CASS_DRIVER_NO_SKIP")) + + +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_makereport(item, call): + """Turn skips into failures when CASS_DRIVER_NO_SKIP is set. + + xfailed tests (which are reported as skipped) are left untouched so that + ``xfail_strict`` keeps working as configured. + """ + outcome = yield + if not _NO_SKIP: + return + report = outcome.get_result() + if report.skipped and not hasattr(report, "wasxfail"): + reason = "" + if isinstance(report.longrepr, tuple) and len(report.longrepr) == 3: + reason = report.longrepr[2] + elif report.longrepr: + reason = str(report.longrepr) + report.outcome = "failed" + report.longrepr = ( + "Test was skipped but skipping is disabled in this environment " + "(CASS_DRIVER_NO_SKIP is set). Run it in a suitable configuration " + "or deselect it explicitly on the command line. " + "Original skip reason: {!r}".format(reason) + ) + def pytest_configure(config): """Warn when a compiled Cython extension is older than its .py source. From 26c201a74c21d038c2bb4b45f1c074a5f40cad2a Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Thu, 18 Jun 2026 12:05:14 +0200 Subject: [PATCH 100/200] Fix Session._set_keyspace_for_all_pools to report all pools' errors The final callback was invoked with host_errors (the errors from only the last pool to finish) instead of the accumulated errors dict. If the last pool succeeded, failures from other pools were silently lost. Pass the aggregated errors dict, matching the method's docstring. --- cassandra/cluster.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 57a8ef10aa..12ade2018f 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -3438,7 +3438,7 @@ def pool_finished_setting_keyspace(pool, host_errors): errors[pool.host] = host_errors if not remaining_callbacks: - callback(host_errors) + callback(errors) for pool in tuple(self._pools.values()): pool._set_keyspace_for_all_conns(keyspace, pool_finished_setting_keyspace) From 34490d3553d8bbd8f184ee2f868b2f1fd57d8fed Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Mon, 22 Jun 2026 14:24:41 +0200 Subject: [PATCH 101/200] test_libevreactor_shutdown: Use installed wheel in subprocess The atexit subprocess test inserted the project root at sys.path[0], which shadows the installed compiled wheel with the in-tree pure-Python source. Under cibuildwheel that source lacks the libev C extension, so the import failed and the subprocess produced no output. Append the project path instead so the installed wheel takes precedence, falling back to the source tree only when the driver is not installed. Also assert the subprocess return code and include stdout/stderr in the failure message so future subprocess import/runtime failures are easier to diagnose. --- tests/unit/io/test_libevreactor_shutdown.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/unit/io/test_libevreactor_shutdown.py b/tests/unit/io/test_libevreactor_shutdown.py index 9578d22df1..e2f76f8a3e 100644 --- a/tests/unit/io/test_libevreactor_shutdown.py +++ b/tests/unit/io/test_libevreactor_shutdown.py @@ -117,8 +117,11 @@ def test_shutdown_cleanup_works_with_fix(self): import sys import os -# Add the driver path -sys.path.insert(0, {driver_path!r}) +# Add the driver path as a fallback only. Append (not insert at 0) so that an +# installed build of the driver (e.g. the compiled wheel under cibuildwheel) +# takes precedence over the in-tree pure-Python source, which lacks the libev +# C extension and would make the import fail. +sys.path.append({driver_path!r}) # Import and setup from cassandra.io import libevreactor @@ -162,9 +165,18 @@ def test_shutdown_cleanup_works_with_fix(self): ) output = result.stdout + error_output = result.stderr print("\n=== Subprocess Output ===") print(output) print("=== End Output ===\n") + print("\n=== Subprocess Error Output ===") + print(error_output) + print("=== End Error Output ===\n") + + self.assertEqual( + result.returncode, 0, + "Subprocess failed\nstdout:\n{}\nstderr:\n{}".format(output, error_output) + ) # Verify the output shows the fix is working self.assertIn("Global loop initialized: True", output) From 8f772c8f7695e27d4aaea94a5e485a4baee958f4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:20:26 +0000 Subject: [PATCH 102/200] build(deps): bump soupsieve from 2.8.3 to 2.8.4 in /docs Bumps [soupsieve](https://github.com/facelessuser/soupsieve) from 2.8.3 to 2.8.4. - [Release notes](https://github.com/facelessuser/soupsieve/releases) - [Commits](https://github.com/facelessuser/soupsieve/compare/2.8.3...2.8.4) --- updated-dependencies: - dependency-name: soupsieve dependency-version: 2.8.4 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- docs/uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/uv.lock b/docs/uv.lock index 3223c9469c..39bced3f24 100644 --- a/docs/uv.lock +++ b/docs/uv.lock @@ -780,11 +780,11 @@ wheels = [ [[package]] name = "soupsieve" -version = "2.8.3" +version = "2.8.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, + { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, ] [[package]] From 6e1257759f3d535720db0275062c5bc30ace3b29 Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Mon, 13 Jul 2026 15:29:00 -0400 Subject: [PATCH 103/200] test: drop USE_CASS_EXTERNAL integration mode --- CONTRIBUTING.rst | 6 ----- tests/integration/__init__.py | 22 +++---------------- tests/integration/conftest.py | 2 +- .../standard/test_authentication.py | 4 ++-- .../test_authentication_misconfiguration.py | 22 +++++++++---------- .../test_control_connection_query_fallback.py | 8 +------ tests/integration/standard/test_query.py | 19 ++++++++-------- 7 files changed, 26 insertions(+), 57 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 82bf21e52f..e8d0e66ddd 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -93,12 +93,6 @@ Or you can specify a scylla/cassandra directory (to test unreleased versions):: SCYLLA_VERSION=/path/to/scylla uv run pytest tests/integration/standard/ -Specifying the usage of an already running Scylla cluster ------------------------------------------------------------- -The test will start the appropriate Scylla clusters when necessary but if you don't want this to happen because a Scylla cluster is already running the flag ``USE_CASS_EXTERNAL`` can be used, for example:: - - USE_CASS_EXTERNAL=1 SCYLLA_VERSION='release:5.1' uv run pytest tests/integration/standard - Specify a Protocol Version for Tests ------------------------------------ The protocol version defaults to: diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py index 5701e5b3da..a91617f494 100644 --- a/tests/integration/__init__.py +++ b/tests/integration/__init__.py @@ -122,7 +122,6 @@ def cmd_line_args_to_dict(env_var): args[cmd_arg.lstrip('-')] = cmd_arg_value return args -USE_CASS_EXTERNAL = bool(os.getenv('USE_CASS_EXTERNAL', False)) KEEP_TEST_CLUSTER = bool(os.getenv('KEEP_TEST_CLUSTER', False)) SIMULACRON_JAR = os.getenv('SIMULACRON_JAR', None) @@ -250,7 +249,7 @@ def get_unsupported_upper_protocol(): def local_decorator_creator(): - if USE_CASS_EXTERNAL or not CASSANDRA_IP.startswith("127.0.0."): + if not CASSANDRA_IP.startswith("127.0.0."): return unittest.skip('Tests only runs against local C*') def _id_and_mark(f): @@ -373,7 +372,7 @@ def check_log_error(): def remove_cluster(): - if USE_CASS_EXTERNAL or KEEP_TEST_CLUSTER: + if KEEP_TEST_CLUSTER: return global CCM_CLUSTER @@ -430,21 +429,6 @@ def use_cluster(cluster_name, nodes, ipformat=None, start=True, workloads=None, cassandra_version = ccm_options.get('version', CCM_VERSION) global CCM_CLUSTER - if USE_CASS_EXTERNAL: - if CCM_CLUSTER: - log.debug("Using external CCM cluster {0}".format(CCM_CLUSTER.name)) - else: - ccm_path = os.getenv("CCM_PATH", None) - ccm_name = os.getenv("CCM_NAME", None) - if ccm_path and ccm_name: - CCM_CLUSTER = CCMClusterFactory.load(ccm_path, ccm_name) - log.debug("Using external CCM cluster {0}".format(CCM_CLUSTER.name)) - else: - log.debug("Using unnamed external cluster") - if set_keyspace and start: - setup_keyspace(ipformat=ipformat) - return - if is_current_cluster(cluster_name, nodes, workloads): log.debug("Using existing cluster, matching topology: {0}".format(cluster_name)) else: @@ -549,7 +533,7 @@ def use_cluster(cluster_name, nodes, ipformat=None, start=True, workloads=None, def teardown_package(): - if USE_CASS_EXTERNAL or KEEP_TEST_CLUSTER: + if KEEP_TEST_CLUSTER: return # when multiple modules are run explicitly, this runs between them # need to make sure CCM_CLUSTER is properly cleared for that case diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 5db8026675..826ba80729 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -20,7 +20,7 @@ def cleanup_clusters(): 'cluster_tests', 'shared_aware', 'sni_proxy', 'test_ip_change', 'test_client_routes_replacement']: try: cluster = CCMClusterFactory.load(ccm_path, cluster_name) - logging.debug("Using external CCM cluster {0}".format(cluster.name)) + logging.debug("Clearing CCM cluster {0}".format(cluster.name)) cluster.clear() except FileNotFoundError: pass diff --git a/tests/integration/standard/test_authentication.py b/tests/integration/standard/test_authentication.py index f172707fff..f23cc324b1 100644 --- a/tests/integration/standard/test_authentication.py +++ b/tests/integration/standard/test_authentication.py @@ -22,7 +22,7 @@ from cassandra.auth import PlainTextAuthProvider, SASLClient, SaslAuthProvider from tests.integration import use_singledc, get_cluster, remove_cluster, PROTOCOL_VERSION, \ - CASSANDRA_IP, CASSANDRA_VERSION, USE_CASS_EXTERNAL, start_cluster_wait_for_up, TestCluster + CASSANDRA_IP, CASSANDRA_VERSION, start_cluster_wait_for_up, TestCluster from tests.integration.util import assert_quiescent_pool_state import unittest @@ -40,7 +40,7 @@ def setup_module(): global _saved_scylla_ext_opts _saved_scylla_ext_opts = os.environ.get('SCYLLA_EXT_OPTS') - if CASSANDRA_IP.startswith("127.0.0.") and not USE_CASS_EXTERNAL: + if CASSANDRA_IP.startswith("127.0.0."): use_singledc(start=False) ccm_cluster = get_cluster() ccm_cluster.stop() diff --git a/tests/integration/standard/test_authentication_misconfiguration.py b/tests/integration/standard/test_authentication_misconfiguration.py index 9ad4ad997d..12397e4a8d 100644 --- a/tests/integration/standard/test_authentication_misconfiguration.py +++ b/tests/integration/standard/test_authentication_misconfiguration.py @@ -15,7 +15,7 @@ import unittest import pytest -from tests.integration import USE_CASS_EXTERNAL, use_cluster, TestCluster +from tests.integration import use_cluster, TestCluster @pytest.mark.skip(reason="Flaky test - needs investigation whether its Scylla's or driver's fault." @@ -24,16 +24,15 @@ class MisconfiguredAuthenticationTests(unittest.TestCase): """ One node (not the contact point) has password auth. The rest of the nodes have no auth """ @classmethod def setUpClass(cls): - if not USE_CASS_EXTERNAL: - ccm_cluster = use_cluster(cls.__name__, [3], start=False) - node3 = ccm_cluster.nodes['node3'] - node3.set_configuration_options(values={ - 'authenticator': 'PasswordAuthenticator', - 'authorizer': 'CassandraAuthorizer', - }) - ccm_cluster.start(wait_for_binary_proto=True, wait_other_notice=True) + ccm_cluster = use_cluster(cls.__name__, [3], start=False) + node3 = ccm_cluster.nodes['node3'] + node3.set_configuration_options(values={ + 'authenticator': 'PasswordAuthenticator', + 'authorizer': 'CassandraAuthorizer', + }) + ccm_cluster.start(wait_for_binary_proto=True, wait_other_notice=True) - cls.ccm_cluster = ccm_cluster + cls.ccm_cluster = ccm_cluster def test_connect_no_auth_provider(self): cluster = TestCluster() @@ -45,5 +44,4 @@ def test_connect_no_auth_provider(self): @classmethod def tearDownClass(cls): - if not USE_CASS_EXTERNAL: - cls.ccm_cluster.stop() + cls.ccm_cluster.stop() diff --git a/tests/integration/standard/test_control_connection_query_fallback.py b/tests/integration/standard/test_control_connection_query_fallback.py index e64763a72c..a9154f681e 100644 --- a/tests/integration/standard/test_control_connection_query_fallback.py +++ b/tests/integration/standard/test_control_connection_query_fallback.py @@ -18,7 +18,7 @@ from cassandra.cluster import ControlConnectionQueryFallback, NoHostAvailable -from tests.integration import USE_CASS_EXTERNAL, TestCluster, local, remove_cluster, use_cluster +from tests.integration import TestCluster, local, remove_cluster, use_cluster _CLUSTER_NAME = "control_connection_query_fallback" @@ -26,9 +26,6 @@ def setup_module(): - if USE_CASS_EXTERNAL: - return - remove_cluster() ccm_cluster = use_cluster(_CLUSTER_NAME, [1], start=False) @@ -39,9 +36,6 @@ def setup_module(): def teardown_module(): - if USE_CASS_EXTERNAL: - return - remove_cluster() diff --git a/tests/integration/standard/test_query.py b/tests/integration/standard/test_query.py index 9f43b0e61a..5f1d5bfc19 100644 --- a/tests/integration/standard/test_query.py +++ b/tests/integration/standard/test_query.py @@ -26,7 +26,7 @@ from cassandra.policies import RoundRobinPolicy, WhiteListRoundRobinPolicy from tests.integration import use_singledc, PROTOCOL_VERSION, BasicSharedKeyspaceUnitTestCase, \ greaterthanprotocolv3, MockLoggingHandler, get_supported_protocol_versions, local, get_cluster, setup_keyspace, \ - USE_CASS_EXTERNAL, greaterthanorequalcass40, TestCluster, xfail_scylla, xfail_scylla_version_lt, \ + greaterthanorequalcass40, TestCluster, xfail_scylla, xfail_scylla_version_lt, \ get_tablets_disabled_ddl_suffix, execute_with_long_wait_retry from tests import notwindows from tests.integration import greaterthanorequalcass30, get_node @@ -43,15 +43,14 @@ def setup_module(): - if not USE_CASS_EXTERNAL: - use_singledc(start=False) - ccm_cluster = get_cluster() - ccm_cluster.stop() - # This is necessary because test_too_many_statements may - # timeout otherwise - config_options = {'write_request_timeout_in_ms': '20000'} - ccm_cluster.set_configuration_options(config_options) - ccm_cluster.start(wait_for_binary_proto=True, wait_other_notice=True) + use_singledc(start=False) + ccm_cluster = get_cluster() + ccm_cluster.stop() + # This is necessary because test_too_many_statements may + # timeout otherwise + config_options = {'write_request_timeout_in_ms': '20000'} + ccm_cluster.set_configuration_options(config_options) + ccm_cluster.start(wait_for_binary_proto=True, wait_other_notice=True) setup_keyspace() From c7f5c98be6ec0dede25632a1f1a4a5330a98eb34 Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Thu, 16 Jul 2026 15:26:16 +0200 Subject: [PATCH 104/200] protocol_features: construct ProtocolFeatures with keyword arguments Make ProtocolFeatures.__init__ keyword-only and build it by keyword in parse_from_supported. Independently developed protocol extensions (SCYLLA_USE_METADATA_ID, TABLETS_ROUTING_V2) each add fields to this class; keyword construction lets them do so without conflicting over positional-argument order. All existing callers already used keywords. --- cassandra/protocol_features.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cassandra/protocol_features.py b/cassandra/protocol_features.py index 877998be7d..1bad379208 100644 --- a/cassandra/protocol_features.py +++ b/cassandra/protocol_features.py @@ -18,7 +18,9 @@ class ProtocolFeatures(object): tablets_routing_v1 = False lwt_info = None - def __init__(self, rate_limit_error=None, shard_id=0, sharding_info=None, tablets_routing_v1=False, lwt_info=None): + # Keyword-only so that independently developed protocol extensions can add + # new fields without conflicting over positional-argument order. + def __init__(self, *, rate_limit_error=None, shard_id=0, sharding_info=None, tablets_routing_v1=False, lwt_info=None): self.rate_limit_error = rate_limit_error self.shard_id = shard_id self.sharding_info = sharding_info @@ -31,7 +33,8 @@ def parse_from_supported(supported): shard_id, sharding_info = ProtocolFeatures.parse_sharding_info(supported) tablets_routing_v1 = ProtocolFeatures.parse_tablets_info(supported) lwt_info = ProtocolFeatures.parse_lwt_info(supported) - return ProtocolFeatures(rate_limit_error, shard_id, sharding_info, tablets_routing_v1, lwt_info) + return ProtocolFeatures(rate_limit_error=rate_limit_error, shard_id=shard_id, sharding_info=sharding_info, + tablets_routing_v1=tablets_routing_v1, lwt_info=lwt_info) @staticmethod def maybe_parse_rate_limit_error(supported): From ea1ff3390eed5b04ded4f524fdd74679ceeb54f6 Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Thu, 16 Jul 2026 15:26:30 +0200 Subject: [PATCH 105/200] protocol: pass negotiated ProtocolFeatures to message serialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connection.send_msg now passes the connection's negotiated ProtocolFeatures to the encoder; _ProtocolHandler.encode_message accepts it as a new required protocol_features argument (passed by keyword from send_msg) and forwards it to every message's send_body, which gains the same parameter. Messages carry connection-independent request data; send_body decides the wire format from (protocol_version, protocol_features), so fields belonging to a negotiated protocol extension are emitted exactly on the connections that negotiated it — on every send path, including the control-connection fallback, and without mutating shared message objects per attempt. This is pure plumbing: no message consumes the parameter yet, so no bytes on the wire change. It is groundwork for the SCYLLA_USE_METADATA_ID (#770) and TABLETS_ROUTING_V2 (#913) extensions, which must serialize extension fields based on what the serving connection negotiated. The encode side becomes symmetric with decode_message, which already receives protocol_features. This changes the contracted signature of encode_message: custom protocol handlers overriding it must accept the protocol_features keyword argument. The argument is deliberately required, with no default and no fallback for old-style encoders: extensions are negotiated per connection at STARTUP before the per-request handler is known, so an encoder unaware of protocol_features could silently omit fields a negotiated extension requires; omitting it fails fast with TypeError instead. Tests: send_msg hands the connection's features to the encoder; encode_message forwards them into send_body (plain and compressed paths) and raises TypeError when the argument is omitted; a byte-identity suite pins frames for representative messages (v3/v4/v5) to the exact bytes produced before this change, both without features and with all-default features. Co-authored-by: Dawid Mędrek --- cassandra/connection.py | 3 +- cassandra/protocol.py | 42 +++++++----- tests/unit/test_connection.py | 20 ++++++ tests/unit/test_protocol.py | 123 +++++++++++++++++++++++++++++++++- 4 files changed, 167 insertions(+), 21 deletions(-) diff --git a/cassandra/connection.py b/cassandra/connection.py index 25508e32ac..f238416b29 100644 --- a/cassandra/connection.py +++ b/cassandra/connection.py @@ -1222,7 +1222,8 @@ def send_msg(self, msg, request_id, cb, encoder=ProtocolHandler.encode_message, # this allows us to inject custom functions per request to encode, decode messages self._requests[request_id] = (cb, decoder, result_metadata) msg = encoder(msg, request_id, self.protocol_version, compressor=self.compressor, - allow_beta_protocol_version=self.allow_beta_protocol_version) + allow_beta_protocol_version=self.allow_beta_protocol_version, + protocol_features=self.features) if self._is_checksumming_enabled: buffer = io.BytesIO() diff --git a/cassandra/protocol.py b/cassandra/protocol.py index bb2865ee53..4360647fb3 100644 --- a/cassandra/protocol.py +++ b/cassandra/protocol.py @@ -424,7 +424,7 @@ def __init__(self, cqlversion, options): self.cqlversion = cqlversion self.options = options - def send_body(self, f, protocol_version): + def send_body(self, f, protocol_version, protocol_features=None): optmap = self.options.copy() optmap['CQL_VERSION'] = self.cqlversion write_stringmap(f, optmap) @@ -459,7 +459,7 @@ class CredentialsMessage(_MessageType): def __init__(self, creds): self.creds = creds - def send_body(self, f, protocol_version): + def send_body(self, f, protocol_version, protocol_features=None): if protocol_version > 1: raise UnsupportedOperation( "Credentials-based authentication is not supported with " @@ -490,7 +490,7 @@ class AuthResponseMessage(_MessageType): def __init__(self, response): self.response = response - def send_body(self, f, protocol_version): + def send_body(self, f, protocol_version, protocol_features=None): write_longstring(f, self.response) @@ -510,7 +510,7 @@ class OptionsMessage(_MessageType): opcode = 0x05 name = 'OPTIONS' - def send_body(self, f, protocol_version): + def send_body(self, f, protocol_version, protocol_features=None): pass @@ -558,7 +558,7 @@ def __init__(self, query_params, consistency_level, self.skip_meta = skip_meta self.keyspace = keyspace - def _write_query_params(self, f, protocol_version): + def _write_query_params(self, f, protocol_version, protocol_features=None): write_consistency_level(f, self.consistency_level) flags = 0x00 if self.query_params is not None: @@ -620,9 +620,9 @@ def __init__(self, query, consistency_level, serial_consistency_level=None, super(QueryMessage, self).__init__(query_params, consistency_level, serial_consistency_level, fetch_size, paging_state, timestamp, False, continuous_paging_options, keyspace) - def send_body(self, f, protocol_version): + def send_body(self, f, protocol_version, protocol_features=None): write_longstring(f, self.query) - self._write_query_params(f, protocol_version) + self._write_query_params(f, protocol_version, protocol_features) class ExecuteMessage(_QueryMessage): @@ -638,14 +638,14 @@ def __init__(self, query_id, query_params, consistency_level, super(ExecuteMessage, self).__init__(query_params, consistency_level, serial_consistency_level, fetch_size, paging_state, timestamp, skip_meta, continuous_paging_options) - def _write_query_params(self, f, protocol_version): - super(ExecuteMessage, self)._write_query_params(f, protocol_version) + def _write_query_params(self, f, protocol_version, protocol_features=None): + super(ExecuteMessage, self)._write_query_params(f, protocol_version, protocol_features) - def send_body(self, f, protocol_version): + def send_body(self, f, protocol_version, protocol_features=None): write_string(f, self.query_id) if ProtocolVersion.uses_prepared_metadata(protocol_version): write_string(f, self.result_metadata_id) - self._write_query_params(f, protocol_version) + self._write_query_params(f, protocol_version, protocol_features) CUSTOM_TYPE = object() @@ -870,7 +870,7 @@ def __init__(self, query, keyspace=None): self.query = query self.keyspace = keyspace - def send_body(self, f, protocol_version): + def send_body(self, f, protocol_version, protocol_features=None): write_longstring(f, self.query) flags = 0x00 @@ -914,7 +914,7 @@ def __init__(self, batch_type, queries, consistency_level, self.timestamp = timestamp self.keyspace = keyspace - def send_body(self, f, protocol_version): + def send_body(self, f, protocol_version, protocol_features=None): write_byte(f, self.batch_type.value) write_short(f, len(self.queries)) for prepared, string_or_query_id, params in self.queries: @@ -972,7 +972,7 @@ class RegisterMessage(_MessageType): def __init__(self, event_list): self.event_list = event_list - def send_body(self, f, protocol_version): + def send_body(self, f, protocol_version, protocol_features=None): write_stringlist(f, self.event_list) @@ -1046,7 +1046,7 @@ def __init__(self, op_type, op_id, next_pages=0): self.op_id = op_id self.next_pages = next_pages - def send_body(self, f, protocol_version): + def send_body(self, f, protocol_version, protocol_features=None): write_int(f, self.op_type) write_int(f, self.op_id) if self.op_type == ReviseRequestMessage.RevisionType.PAGING_BACKPRESSURE: @@ -1079,7 +1079,8 @@ class _ProtocolHandler(object): """Instance of :class:`cassandra.policies.ColumnEncryptionPolicy` in use by this handler""" @classmethod - def encode_message(cls, msg, stream_id, protocol_version, compressor, allow_beta_protocol_version): + def encode_message(cls, msg, stream_id, protocol_version, compressor, allow_beta_protocol_version, + protocol_features): """ Encodes a message using the specified frame parameters, and compressor @@ -1087,6 +1088,11 @@ def encode_message(cls, msg, stream_id, protocol_version, compressor, allow_beta :param stream_id: protocol stream id for the frame header :param protocol_version: version for the frame header, and used encoding contents :param compressor: optional compression function to be used on the body + :param protocol_features: :class:`~cassandra.protocol_features.ProtocolFeatures` negotiated on the connection + this message is sent over, forwarded to ``send_body``. Messages carry + connection-independent request data; ``send_body`` decides the wire format from + ``(protocol_version, protocol_features)``, so fields belonging to a negotiated + protocol extension are emitted exactly on the connections that negotiated it. """ flags = 0 if msg.custom_payload: @@ -1108,7 +1114,7 @@ def encode_message(cls, msg, stream_id, protocol_version, compressor, allow_beta body = io.BytesIO() if msg.custom_payload: write_bytesmap(body, msg.custom_payload) - msg.send_body(body, protocol_version) + msg.send_body(body, protocol_version, protocol_features) body = body.getvalue() if len(body) > 0: @@ -1120,7 +1126,7 @@ def encode_message(cls, msg, stream_id, protocol_version, compressor, allow_beta else: if msg.custom_payload: write_bytesmap(buff, msg.custom_payload) - msg.send_body(buff, protocol_version) + msg.send_body(buff, protocol_version, protocol_features) length = buff.tell() - 9 diff --git a/tests/unit/test_connection.py b/tests/unit/test_connection.py index cf4607fbed..1f9a3f682c 100644 --- a/tests/unit/test_connection.py +++ b/tests/unit/test_connection.py @@ -291,6 +291,26 @@ def test_set_keyspace_async_escapes_quotes(self): assert query_msg.query == 'USE "my""ks"', ( "Double quotes in keyspace name must be escaped as double-double quotes") + def test_send_msg_passes_negotiated_features_to_encoder(self): + """ + send_msg must hand the connection's negotiated ProtocolFeatures to the + encoder, so message serialization can emit fields belonging to protocol + extensions exactly on the connections that negotiated them. + """ + c = self.make_connection() + c.push = Mock() + captured = {} + + def encoder(msg, stream_id, protocol_version, compressor, allow_beta_protocol_version, + protocol_features=None): + captured['protocol_features'] = protocol_features + return b'encoded-frame' + + c.send_msg(Mock(), 1, cb=Mock(), encoder=encoder, decoder=Mock()) + + assert captured['protocol_features'] is c.features + c.push.assert_called_once_with(b'encoded-frame') + def test_set_connection_class(self): cluster = Cluster(connection_class='test') assert 'test' == cluster.connection_class diff --git a/tests/unit/test_protocol.py b/tests/unit/test_protocol.py index da47f3f08c..db6c37abda 100644 --- a/tests/unit/test_protocol.py +++ b/tests/unit/test_protocol.py @@ -16,11 +16,13 @@ from unittest.mock import Mock -from cassandra import ProtocolVersion, UnsupportedOperation +from cassandra import ConsistencyLevel, ProtocolVersion, UnsupportedOperation from cassandra.protocol import ( PrepareMessage, QueryMessage, ExecuteMessage, UnsupportedOperation, - BatchMessage + BatchMessage, StartupMessage, OptionsMessage, RegisterMessage, + AuthResponseMessage, ProtocolHandler, _MessageType ) +from cassandra.protocol_features import ProtocolFeatures from cassandra.query import BatchType import pytest @@ -185,3 +187,120 @@ def test_batch_message_with_keyspace(self): (b'\x00\x03',), (b'\x00\x00\x00\x80',), (b'\x00\x02',), (b'ks',)) ) + + +class ProtocolFeaturesPlumbingTest(unittest.TestCase): + """ + The negotiated ProtocolFeatures must flow from encode_message into each + message's send_body, so serialization can emit fields belonging to + protocol extensions exactly on the connections that negotiated them. + """ + + class CapturingMessage(_MessageType): + opcode = 0x00 + name = 'CAPTURE' + + def __init__(self): + self.seen_features = [] + + def send_body(self, f, protocol_version, protocol_features=None): + self.seen_features.append(protocol_features) + + def test_encode_message_forwards_protocol_features_to_send_body(self): + features = ProtocolFeatures() + msg = self.CapturingMessage() + ProtocolHandler.encode_message(msg, stream_id=0, protocol_version=4, compressor=None, + allow_beta_protocol_version=False, protocol_features=features) + assert msg.seen_features == [features] + assert msg.seen_features[0] is features + + def test_encode_message_forwards_protocol_features_when_compressing(self): + features = ProtocolFeatures() + msg = self.CapturingMessage() + ProtocolHandler.encode_message(msg, stream_id=0, protocol_version=4, compressor=lambda body: body, + allow_beta_protocol_version=False, protocol_features=features) + assert msg.seen_features[0] is features + + def test_encode_message_fails_without_protocol_features(self): + msg = self.CapturingMessage() + + with pytest.raises(TypeError, match='positional argument'): + ProtocolHandler.encode_message(msg, stream_id=0, protocol_version=4, compressor=None, + allow_beta_protocol_version=False) + + +class FrameByteIdentityTest(unittest.TestCase): + """ + Threading ProtocolFeatures into serialization is pure plumbing: with no + extension consuming it (and for all-default features), every frame must be + byte-identical to what the driver produced before the parameter existed. + The expected frames below were captured from the pre-change encoder. + """ + + EXPECTED_FRAMES = { + 'startup_v4': '0400000701000000160001000b43514c5f56455253494f4e0005332e342e35', + 'options_v4': '040000070500000000', + 'register_v4': '040000070b000000220002000f544f504f4c4f47595f4348414e4745000d5354415455535f4348414e4745', + 'auth_response_v4': '040000070f0000000e0000000a00757365720070617373', + 'prepare_v4': '0400000709000000220000001e53454c454354202a2046524f4d206b732e74205748455245206b203d203f', + 'prepare_v5_keyspace': '0500000709000000270000001b53454c454354202a2046524f4d2074205748455245206b203d203f0000000100026b73', + 'query_v3': '0300000707000000270000001253454c454354202a2046524f4d206b732e74000434000013880008000462d53c8abac0', + 'execute_v3': '030000070a00000033000412345678000a2d000200000002000100000003616263000000640000000b504147494e475354415445000000003ade68b1', + 'batch_v3': '030000070d00000043000002000000001f494e5345525420494e544f206b732e7420286b292056414c5545532028312900000100041234567800010000000200020001200000000006a11e3d', + 'query_v4': '0400000707000000270000001253454c454354202a2046524f4d206b732e74000434000013880008000462d53c8abac0', + 'execute_v4': '040000070a00000033000412345678000a2d000200000002000100000003616263000000640000000b504147494e475354415445000000003ade68b1', + 'batch_v4': '040000070d00000043000002000000001f494e5345525420494e544f206b732e7420286b292056414c5545532028312900000100041234567800010000000200020001200000000006a11e3d', + 'query_v5': '05000007070000002a0000001253454c454354202a2046524f4d206b732e74000400000034000013880008000462d53c8abac0', + 'execute_v5': '050000070a0000003c0004123456780004aabbccdd000a0000002d000200000002000100000003616263000000640000000b504147494e475354415445000000003ade68b1', + 'batch_v5': '050000070d00000046000002000000001f494e5345525420494e544f206b732e7420286b292056414c5545532028312900000100041234567800010000000200020001000000200000000006a11e3d', + } + + @staticmethod + def _make_cases(): + cases = [ + ('startup_v4', StartupMessage(cqlversion="3.4.5", options={}), 4), + ('options_v4', OptionsMessage(), 4), + ('register_v4', RegisterMessage(["TOPOLOGY_CHANGE", "STATUS_CHANGE"]), 4), + ('auth_response_v4', AuthResponseMessage(b"\x00user\x00pass"), 4), + ('prepare_v4', PrepareMessage("SELECT * FROM ks.t WHERE k = ?"), 4), + ('prepare_v5_keyspace', PrepareMessage("SELECT * FROM t WHERE k = ?", keyspace="ks"), 5), + ] + for pv in (3, 4, 5): + cases.append(( + 'query_v%d' % pv, + QueryMessage("SELECT * FROM ks.t", ConsistencyLevel.QUORUM, + serial_consistency_level=ConsistencyLevel.SERIAL, + fetch_size=5000, timestamp=1234567890123456), + pv, + )) + cases.append(( + 'execute_v%d' % pv, + ExecuteMessage(b"\x12\x34\x56\x78", [b"\x00\x01", b"abc"], + ConsistencyLevel.LOCAL_ONE, fetch_size=100, + paging_state=b"PAGINGSTATE", + result_metadata_id=b"\xaa\xbb\xcc\xdd" if pv >= 5 else None, + timestamp=987654321), + pv, + )) + cases.append(( + 'batch_v%d' % pv, + BatchMessage(BatchType.LOGGED, + [(False, "INSERT INTO ks.t (k) VALUES (1)", []), + (True, b"\x12\x34\x56\x78", [b"\x00\x02"])], + ConsistencyLevel.ONE, timestamp=111222333), + pv, + )) + return cases + + def _assert_frames(self, protocol_features): + for name, msg, pv in self._make_cases(): + frame = ProtocolHandler.encode_message( + msg, stream_id=7, protocol_version=pv, compressor=None, + allow_beta_protocol_version=False, protocol_features=protocol_features) + assert frame.hex() == self.EXPECTED_FRAMES[name], name + + def test_frames_without_features(self): + self._assert_frames(None) + + def test_frames_with_default_features(self): + self._assert_frames(ProtocolFeatures()) From e605de280931e60645c266304a2b654eb23825ee Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Thu, 16 Jul 2026 15:26:38 +0200 Subject: [PATCH 106/200] docs: document encode_message contract change Note in the protocol API docs that both contracted _ProtocolHandler methods receive the connection's negotiated ProtocolFeatures, spelling out the calling conventions: decode_message receives it positionally, encode_message as the required protocol_features keyword argument, so overrides must keep that parameter name. Add a CHANGELOG entry with upgrade guidance for custom protocol handlers (accept protocol_features, prefer **kwargs for future-proofing, forward it when delegating to send_body). --- CHANGELOG.rst | 18 ++++++++++++++++++ docs/api/cassandra/protocol.rst | 5 +++++ 2 files changed, 23 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 72ad29fae7..bebb27c82e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,21 @@ +Unreleased +========== + +Others +------ +* Message serialization now receives the connection's negotiated ``ProtocolFeatures``: + ``Connection.send_msg`` passes ``protocol_features`` to the encoder, and + ``_ProtocolHandler.encode_message`` forwards it to each message's ``send_body``. + This changes the contracted signature of ``encode_message`` (and of ``send_body``). + Custom protocol handlers that override ``encode_message`` must accept a required + ``protocol_features`` keyword argument (adding ``**kwargs`` is recommended for + future-proofing), and custom encoders that delegate to ``msg.send_body`` should + forward it. There is deliberately no compatibility fallback: protocol extensions + are negotiated per connection at STARTUP, so an encoder unaware of + ``protocol_features`` could silently omit fields a negotiated extension requires. + This release emits no new bytes on the wire; the parameter is groundwork for + upcoming protocol extensions (``SCYLLA_USE_METADATA_ID``, ``TABLETS_ROUTING_V2``). + 3.29.11 ======= Jun 15, 2026 diff --git a/docs/api/cassandra/protocol.rst b/docs/api/cassandra/protocol.rst index 8b8f303574..745011c01a 100644 --- a/docs/api/cassandra/protocol.rst +++ b/docs/api/cassandra/protocol.rst @@ -27,6 +27,11 @@ See :meth:`.Session.execute`, :meth:`.Session.execute_async`, :attr:`.ResponseFu .. automethod:: decode_message +.. note:: + Both contracted methods receive the ``ProtocolFeatures`` negotiated on the connection + carrying the message: ``decode_message`` positionally, ``encode_message`` as the required + ``protocol_features`` keyword argument (overrides must keep that parameter name). + .. _faster_deser: Faster Deserialization From bcc2d3d0c02973065fb17e2416286cddcf76a017 Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Mon, 20 Jul 2026 08:24:33 -0400 Subject: [PATCH 107/200] pool: honor explicit SSL config for shard-aware ports Problem: shard-aware endpoint selection treated legacy ssl_options as SSL-enabled only when the dict was truthy. An explicit empty ssl_options={} was therefore handled like plaintext and could select the non-SSL shard-aware port, diverging from the cluster-level SSL-enabled check. Fix: treat SSL as enabled when ssl_context is set or ssl_options is not None. SSL-enabled configurations now use the SSL shard-aware port when advertised and otherwise fall back to regular non-shard-aware connections instead of the plaintext shard-aware port. Unit coverage now includes ssl_context, non-empty ssl_options, and empty ssl_options. --- cassandra/pool.py | 17 +++++++--- tests/unit/test_shard_aware.py | 59 ++++++++++++++++++++++++++++------ 2 files changed, 62 insertions(+), 14 deletions(-) diff --git a/cassandra/pool.py b/cassandra/pool.py index 18bed1bbdc..176751f60a 100644 --- a/cassandra/pool.py +++ b/cassandra/pool.py @@ -675,15 +675,26 @@ def disable_advanced_shard_aware(self, secs): self.advanced_shardaware_block_until = max(time.time() + secs, self.advanced_shardaware_block_until) def _get_shard_aware_endpoint(self): + """ + Return an endpoint for the advertised shard-aware port, if usable. + + Plaintext clusters use shard_aware_port. SSL-enabled clusters use only + shard_aware_port_ssl; if it is absent, return None so the pool opens a + regular SSL connection instead of falling back to the plaintext port. + Explicit ssl_options={}, like ssl_context, marks the cluster SSL-enabled. + """ if (self.advanced_shardaware_block_until and self.advanced_shardaware_block_until > time.time()) or \ self._session.cluster.shard_aware_options.disable_shardaware_port: return None + cluster = self._session.cluster + ssl_enabled = cluster.ssl_context is not None or cluster.ssl_options is not None + endpoint = None - if self._session.cluster.ssl_options and self.host.sharding_info.shard_aware_port_ssl: + if ssl_enabled and self.host.sharding_info.shard_aware_port_ssl: endpoint = copy.copy(self.host.endpoint) endpoint._port = self.host.sharding_info.shard_aware_port_ssl - elif self.host.sharding_info.shard_aware_port: + elif not ssl_enabled and self.host.sharding_info.shard_aware_port: endpoint = copy.copy(self.host.endpoint) endpoint._port = self.host.sharding_info.shard_aware_port @@ -918,5 +929,3 @@ def open_count(self): @property def _excess_connection_limit(self): return self.host.sharding_info.shards_count * self.max_excess_connections_per_shard_multiplier - - diff --git a/tests/unit/test_shard_aware.py b/tests/unit/test_shard_aware.py index 4b4c2c138d..902b48a276 100644 --- a/tests/unit/test_shard_aware.py +++ b/tests/unit/test_shard_aware.py @@ -32,19 +32,19 @@ class MockSession(MagicMock): is_shutdown = False keyspace = "ks1" - def __init__(self, is_ssl=False, *args, **kwargs): + def __init__(self, ssl_options=None, ssl_context=None, sharding_info=None, + *args, **kwargs): super(MockSession, self).__init__(*args, **kwargs) self.cluster = MagicMock() - if is_ssl: - self.cluster.ssl_options = {'some_ssl_options': True} - else: - self.cluster.ssl_options = None + self.cluster.ssl_options = ssl_options + self.cluster.ssl_context = ssl_context self.cluster.shard_aware_options = ShardAwareOptions() self.cluster.executor = ThreadPoolExecutor(max_workers=2) self.cluster.signal_connection_failure = lambda *args, **kwargs: False self.cluster.connection_factory = self.mock_connection_factory self.connection_counter = 0 self.futures = [] + self.sharding_info = sharding_info def submit(self, fn, *args, **kwargs): logging.info("Scheduling %s with args: %s, kwargs: %s", fn, args, kwargs) @@ -60,8 +60,13 @@ def mock_connection_factory(self, *args, **kwargs): connection.is_closed = False connection.orphaned_threshold_reached = False connection.endpoint = args[0] - sharding_info = ShardingInfo(shard_id=1, shards_count=4, partitioner="", sharding_algorithm="", sharding_ignore_msb=0, shard_aware_port=19042, shard_aware_port_ssl=19045) - connection.features = ProtocolFeatures(shard_id=kwargs.get('shard_id', self.connection_counter), sharding_info=sharding_info) + sharding_info = self.sharding_info or ShardingInfo( + shard_id=1, shards_count=4, partitioner="", + sharding_algorithm="", sharding_ignore_msb=0, + shard_aware_port=19042, shard_aware_port_ssl=19045) + connection.features = ProtocolFeatures( + shard_id=kwargs.get('shard_id', self.connection_counter), + sharding_info=sharding_info) self.connection_counter += 1 return connection @@ -98,8 +103,12 @@ def test_advanced_shard_aware_port(self): host = MagicMock() host.endpoint = DefaultEndPoint("1.2.3.4") - for port, is_ssl in [(19042, False), (19045, True)]: - session = MockSession(is_ssl=is_ssl) + for port, ssl_options, ssl_context in [ + (19042, None, None), + (19045, {'some_ssl_options': True}, None), + (19045, {}, None), + (19045, None, object())]: + session = MockSession(ssl_options=ssl_options, ssl_context=ssl_context) pool = HostConnection(host=host, host_distance=HostDistance.REMOTE, session=session) try: for f in session.futures: @@ -114,6 +123,36 @@ def test_advanced_shard_aware_port(self): finally: session.cluster.executor.shutdown(wait=True) + def test_ssl_advanced_shard_aware_port_requires_ssl_port(self): + """ + Test that SSL connections do not fall back to the plaintext + shard-aware port when the SSL shard-aware port is unavailable. + """ + host = MagicMock() + host.endpoint = DefaultEndPoint("1.2.3.4") + sharding_info = ShardingInfo( + shard_id=1, shards_count=4, partitioner="", sharding_algorithm="", + sharding_ignore_msb=0, shard_aware_port=19042, + shard_aware_port_ssl=None) + for label, ssl_options, ssl_context in [ + ('ssl_options', {'some_ssl_options': True}, None), + ('empty_ssl_options', {}, None), + ('ssl_context', None, object())]: + with self.subTest(label=label): + session = MockSession( + ssl_options=ssl_options, + ssl_context=ssl_context, + sharding_info=sharding_info) + pool = HostConnection(host=host, host_distance=HostDistance.REMOTE, session=session) + + try: + for f in session.futures: + f.result() + + assert pool._get_shard_aware_endpoint() is None + finally: + session.cluster.executor.shutdown(wait=True) + def test_advanced_shard_aware_cooldown(self): """ `disable_advanced_shard_aware` must suppress the shard-aware endpoint for @@ -123,7 +162,7 @@ def test_advanced_shard_aware_cooldown(self): """ host = MagicMock() host.endpoint = DefaultEndPoint("1.2.3.4") - session = MockSession(is_ssl=False) + session = MockSession() pool = HostConnection(host=host, host_distance=HostDistance.REMOTE, session=session) for f in session.futures: From fa14e802028e77ac4176056e47dd87fbf7ef5426 Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Thu, 16 Jul 2026 21:51:39 +0200 Subject: [PATCH 108/200] DRIVER-153: negotiate and implement SCYLLA_USE_METADATA_ID extension Implement the SCYLLA_USE_METADATA_ID protocol extension, which backports the CQL v5 prepared-statement metadata-id mechanism to earlier protocol versions. When negotiated, the server includes a hash of the result metadata in the PREPARE response; the driver sends it back with every EXECUTE, allowing the server to omit result metadata from responses (skip_meta) and to report schema changes with METADATA_CHANGED plus fresh metadata, which the driver adopts automatically. protocol_features.py: parse the extension from SUPPORTED, echo it in STARTUP, expose it as ProtocolFeatures.use_metadata_id. protocol.py: ExecuteMessage carries connection-independent request data (skip_meta, result_metadata_id) fixed at construction; serialization decides the wire format from the (protocol_version, protocol_features) that Connection.send_msg supplies for the serving connection: - The metadata-id field is written iff the connection speaks CQL v5+ or negotiated the extension - always, on such connections. An empty sentinel (b'') is written when the statement has no id (prepared before the extension was active, e.g. during a rolling upgrade, or an LWT statement): the sentinel mismatch makes the server respond with METADATA_CHANGED plus the current id and metadata, so such statements acquire an id on their first execution. This also fixes a TypeError on v5 when result_metadata_id was None. - _SKIP_METADATA_FLAG is written only when the SCYLLA_USE_METADATA_ID extension is negotiated on the connection; without the metadata-id mechanism a schema change after PREPARE would leave the driver decoding rows with stale cached metadata. This is deliberately narrower than the metadata-id field above: on native CQL v5 the field is part of the frame layout, but the driver does not request skip there. Upstream never emitted _SKIP_METADATA_FLAG on any version (_write_query_params never wrote it), and enabling the skip optimization for native v5 is a separate change kept out of scope for this Scylla extension. Because messages are immutable after construction, every send path is correct without per-path setup - including the control-connection fallback - and concurrent sends of the same message (speculative executions) cannot race on per-connection state. query.py: PreparedStatement stores (result_metadata, result_metadata_id) as one tuple replaced in a single attribute assignment, read through compatibility properties and updated via update_result_metadata(). Response callbacks update statements while request threads read them; a torn pair (fresh id + stale metadata) would make the server skip sending metadata while rows are decoded against the wrong columns, with no recovery. The compatibility setters are documented as non-atomic relative to each other - update_result_metadata() is the atomic path; the setters exist only for callers assigning the old individual attributes. cluster.py: _create_response_future snapshots the pair once and requests skip_meta only when the statement has both an id and usable cached metadata (result_metadata is None for NO_METADATA/LWT statements and [] for zero-column statements; neither can nor needs to skip metadata). The same snapshot is handed to the ResponseFuture, so a skip_meta response is decoded against the metadata that pairs with the id the message sent - not a later re-read of the statement cache, which a concurrent METADATA_CHANGED could have replaced between construction and send (and which also keeps speculative sends of one message internally consistent). _set_result adopts a METADATA_CHANGED response by replacing the pair atomically; a response carrying a new id without column metadata is ignored with a warning, since adopting the id alone would create the unrecoverable stale-decode state. skip_meta additionally stays off for continuous paging (@dkropachev): Connection.process_msg hardcodes result_metadata=None for every page after the first, since it isn't threaded through the paging session - a skip_meta response has nothing to decode page 2+ against, and would crash on it. _execute_after_prepare refreshes the pair from exactly what the reprepare response carries, including the id (@dkropachev): falling back to the previously cached id when the response has none risks pairing it with metadata from a different schema version than the one that id was computed for - e.g. if the schema changed and then reverted between the two PREPAREs, the old id can become valid again for the current schema while paired locally with an intermediate version's metadata, with no server-side mismatch to catch it. Dropping it instead lets the next id-aware execute re-acquire a correctly paired id through the same b'' sentinel self-healing path a never-prepared statement uses. docs/scylla-specific.rst: documents the extension and its behaviour, worded so the skip_meta optimization reads as conditional on the extension being negotiated rather than pre-existing default behaviour. CHANGELOG.rst: add a Features entry for the extension. --- CHANGELOG.rst | 11 +++++ cassandra/cluster.py | 81 +++++++++++++++++++++++++++++----- cassandra/protocol.py | 49 ++++++++++++++++++-- cassandra/protocol_features.py | 17 ++++++- cassandra/query.py | 56 +++++++++++++++++++++-- docs/scylla-specific.rst | 52 ++++++++++++++++++++++ 6 files changed, 247 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index bebb27c82e..2a02f1ac54 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,8 +1,19 @@ Unreleased ========== +Features +-------- +* Negotiate and implement the ``SCYLLA_USE_METADATA_ID`` protocol extension: prepared + statements skip re-sending result metadata on EXECUTE, and the driver automatically + refreshes cached metadata when the server detects a schema change (DRIVER-153) + Others ------ +* ``PreparedStatement.result_metadata`` and ``PreparedStatement.result_metadata_id`` are + now read-only. They are replaced together by + ``PreparedStatement.update_result_metadata()``, so a request can never observe a metadata + id paired with result metadata from a different schema version. Code that assigned either + attribute directly must call ``update_result_metadata()`` instead. * Message serialization now receives the connection's negotiated ``ProtocolFeatures``: ``Connection.send_msg`` passes ``protocol_features`` to the encoder, and ``_ProtocolHandler.encode_message`` forwards it to each message's ``send_body``. diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 12ade2018f..87d74865a2 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -3047,6 +3047,10 @@ def _create_response_future(self, query, parameters, trace, custom_payload, else: timestamp = None + # Snapshot passed to the ResponseFuture for decoding skip_meta responses; only + # bound statements carry cached result metadata (set in the BoundStatement branch). + bound_result_metadata = _NOT_SET + if isinstance(query, SimpleStatement): query_string = query.query_string statement_keyspace = query.keyspace if ProtocolVersion.uses_keyspace_flag(self._protocol_version) else None @@ -3058,12 +3062,27 @@ def _create_response_future(self, query, parameters, trace, custom_payload, continuous_paging_options, statement_keyspace) elif isinstance(query, BoundStatement): prepared_statement = query.prepared_statement + # Snapshot metadata and its id as one atomic pair so the message never + # carries the id of one schema version alongside a skip_meta decision + # made for another. skip_meta is requested only when there is both an + # id to validate it with and cached metadata to decode against: while + # a statement has no cached metadata there is nothing to decode a + # metadata-less response with, so the server must send it. + # Whether skip_meta and the id actually reach the wire is decided per + # connection at serialization time (see ExecuteMessage.send_body). + # Continuous paging sessions are excluded: Connection.process_msg hardcodes + # result_metadata=None for every page after the first (it isn't threaded + # through the paging session), so a skip_meta response has nothing to + # decode page 2+ against. + result_metadata, result_metadata_id = prepared_statement.result_metadata_and_id + bound_result_metadata = result_metadata message = ExecuteMessage( prepared_statement.query_id, query.values, cl, serial_cl, fetch_size, paging_state, timestamp, - skip_meta=bool(prepared_statement.result_metadata), + skip_meta=bool(result_metadata) and result_metadata_id is not None + and continuous_paging_options is None, continuous_paging_options=continuous_paging_options, - result_metadata_id=prepared_statement.result_metadata_id) + result_metadata_id=result_metadata_id) elif isinstance(query, BatchStatement): if self._protocol_version < 2: raise UnsupportedOperation( @@ -3090,7 +3109,7 @@ def _create_response_future(self, query, parameters, trace, custom_payload, self, message, query, timeout, metrics=self._metrics, prepared_statement=prepared_statement, retry_policy=retry_policy, row_factory=row_factory, load_balancer=load_balancing_policy, start_time=start_time, speculative_execution_plan=spec_exec_plan, - continuous_paging_state=None, host=host) + continuous_paging_state=None, host=host, bound_result_metadata=bound_result_metadata) def get_execution_profile(self, name): """ @@ -4717,12 +4736,14 @@ class ResponseFuture(object): _host = None _control_connection_query_attempted = False _TABLET_ROUTING_CTYPE = None + _bound_result_metadata = [] _warned_timeout = False def __init__(self, session, message, query, timeout, metrics=None, prepared_statement=None, retry_policy=RetryPolicy(), row_factory=None, load_balancer=None, start_time=None, - speculative_execution_plan=None, continuous_paging_state=None, host=None): + speculative_execution_plan=None, continuous_paging_state=None, host=None, + bound_result_metadata=_NOT_SET): self.session = session # TODO: normalize handling of retry policy and row factory self.row_factory = row_factory or session.row_factory @@ -4733,6 +4754,12 @@ def __init__(self, session, message, query, timeout, metrics=None, prepared_stat self._retry_policy = retry_policy self._metrics = metrics self.prepared_statement = prepared_statement + # Metadata snapshotted alongside the message's result_metadata_id at construction + # time (see Session._create_response_future). Decoding a skip_meta response uses + # this so the metadata decoded-with always pairs with the id the message sent, + # even if a concurrent METADATA_CHANGED replaces the prepared statement's cache in + # between. Defaults to [] for unprepared statements (no cached metadata). + self._bound_result_metadata = [] if bound_result_metadata is _NOT_SET else bound_result_metadata self._callback_lock = Lock() self._start_time = start_time or time.time() self._host = host @@ -4956,7 +4983,7 @@ def _query_control_connection(self, message=None, cb=None, connection=None, host try: request_id = self._borrow_control_connection(connection) self._connection = connection - result_meta = self.prepared_statement.result_metadata if self.prepared_statement else [] + result_meta = self._bound_result_metadata if cb is None: cb = partial(self._set_result, host, connection, None) cb = partial(self._handle_control_connection_response, connection, cb) @@ -5010,7 +5037,7 @@ def _query(self, host, message=None, cb=None): else: connection, request_id = pool.borrow_connection(timeout=2.0) self._connection = connection - result_meta = self.prepared_statement.result_metadata if self.prepared_statement else [] + result_meta = self._bound_result_metadata if cb is None: cb = partial(self._set_result, host, connection, pool) @@ -5175,6 +5202,33 @@ def _set_result(self, host, connection, pool, response): self._paging_state = response.paging_state self._col_names = response.column_names self._col_types = response.column_types + new_result_metadata_id = getattr(response, 'result_metadata_id', None) + if self.prepared_statement and new_result_metadata_id is not None: + if response.column_metadata: + # METADATA_CHANGED: replace metadata and its id as one + # atomic pair so a concurrent reader can never pair the + # new id with the old metadata (the server would then + # skip sending metadata and rows would be decoded + # against stale columns, with no recovery). + # (this also re-arms the anomaly warning below) + self.prepared_statement.update_result_metadata( + response.column_metadata, new_result_metadata_id) + elif not self.prepared_statement._warned_missing_column_metadata: + # Anomalous response: a new id without the metadata it + # describes. Cache neither — adopting the id alone would + # create exactly the stale-metadata/fresh-id state + # described above. Keeping the old pair means the next + # EXECUTE sends the old id, the server detects the + # mismatch, and the driver recovers with full metadata. + # Log once per statement (not per execute) while the + # anomaly persists. + self.prepared_statement._warned_missing_column_metadata = True + log.warning( + "Server sent a new result_metadata_id but no column metadata " + "for prepared statement %r. Ignoring both; the cached metadata " + "and id are left unchanged.", + getattr(self.prepared_statement, 'query_id', None) + ) if getattr(self.message, 'continuous_paging_options', None): self._handle_continuous_paging_first_response(connection, response) else: @@ -5325,10 +5379,17 @@ def _execute_after_prepare(self, host, connection, pool, response): expected=hexlify(self.prepared_statement.query_id), got=hexlify(response.query_id) ) )) - self.prepared_statement.result_metadata = response.column_metadata - new_metadata_id = response.result_metadata_id - if new_metadata_id is not None: - self.prepared_statement.result_metadata_id = new_metadata_id + # Update the metadata/id pair atomically from exactly what this + # reprepare response carries. Falling back to the previously + # cached id when this response has none would risk pairing it + # with metadata from a different schema version than the one the + # old id was computed for (e.g. schema changed and reverted + # between the two PREPAREs) - a stale-but-plausible id a later + # id-aware execute could send without the server detecting the + # mismatch. Dropping it instead triggers the same self-healing + # b'' sentinel path a never-prepared id would. + self.prepared_statement.update_result_metadata( + response.column_metadata, response.result_metadata_id) # use self._query to re-use the same host and # at the same time properly borrow the connection diff --git a/cassandra/protocol.py b/cassandra/protocol.py index 4360647fb3..9dfdbf3022 100644 --- a/cassandra/protocol.py +++ b/cassandra/protocol.py @@ -558,6 +558,14 @@ def __init__(self, query_params, consistency_level, self.skip_meta = skip_meta self.keyspace = keyspace + def _should_skip_metadata(self, protocol_version, protocol_features): + """Whether to set ``_SKIP_METADATA_FLAG`` on this message. + + The base is unconditional (the message's own ``skip_meta``); subclasses + narrow it based on the connection's negotiated features. + """ + return self.skip_meta + def _write_query_params(self, f, protocol_version, protocol_features=None): write_consistency_level(f, self.consistency_level) flags = 0x00 @@ -576,6 +584,9 @@ def _write_query_params(self, f, protocol_version, protocol_features=None): if self.timestamp is not None: flags |= _PROTOCOL_TIMESTAMP_FLAG + if self._should_skip_metadata(protocol_version, protocol_features): + flags |= _SKIP_METADATA_FLAG + if self.keyspace is not None: if ProtocolVersion.uses_keyspace_flag(protocol_version): flags |= _WITH_KEYSPACE_FLAG @@ -625,6 +636,17 @@ def send_body(self, f, protocol_version, protocol_features=None): self._write_query_params(f, protocol_version, protocol_features) +def _metadata_id_negotiated(protocol_version, protocol_features): + """Whether the result-metadata-id field is part of the frame layout. + + It is part of the layout of EXECUTE requests and PREPARE responses whenever + the connection speaks CQL v5+ natively or negotiated SCYLLA_USE_METADATA_ID, + so on such connections it must always be written and always be read. + """ + return (ProtocolVersion.uses_prepared_metadata(protocol_version) + or (protocol_features is not None and protocol_features.use_metadata_id)) + + class ExecuteMessage(_QueryMessage): opcode = 0x0A name = 'EXECUTE' @@ -638,13 +660,34 @@ def __init__(self, query_id, query_params, consistency_level, super(ExecuteMessage, self).__init__(query_params, consistency_level, serial_consistency_level, fetch_size, paging_state, timestamp, skip_meta, continuous_paging_options) + def _should_skip_metadata(self, protocol_version, protocol_features): + """Whether to ask the server to skip sending result metadata. + + Only when the SCYLLA_USE_METADATA_ID extension is negotiated on this + connection. Without the metadata-id mechanism a schema change after + PREPARE would leave the driver decoding rows with stale cached metadata. + + This is deliberately narrower than :func:`_metadata_id_negotiated`: on + native CQL v5 the metadata-id field is part of the frame layout, but we + do NOT emit ``_SKIP_METADATA_FLAG`` there. Upstream never emitted it on + any version, and turning the skip optimization on for native v5 is a + separate behavior change out of scope for this Scylla extension. + """ + return (self.skip_meta + and protocol_features is not None + and protocol_features.use_metadata_id) + def _write_query_params(self, f, protocol_version, protocol_features=None): super(ExecuteMessage, self)._write_query_params(f, protocol_version, protocol_features) def send_body(self, f, protocol_version, protocol_features=None): write_string(f, self.query_id) - if ProtocolVersion.uses_prepared_metadata(protocol_version): - write_string(f, self.result_metadata_id) + if _metadata_id_negotiated(protocol_version, protocol_features): + # An empty id is written when the statement has no cached metadata id + # (prepared before the extension was negotiated, e.g. in a mixed + # cluster): the server treats the mismatch as METADATA_CHANGED and + # responds with full metadata plus the current id. + write_string(f, self.result_metadata_id if self.result_metadata_id is not None else b'') self._write_query_params(f, protocol_version, protocol_features) @@ -748,7 +791,7 @@ def decode_row(row): def recv_results_prepared(self, f, protocol_version, protocol_features, user_type_map): self.query_id = read_binary_string(f) - if ProtocolVersion.uses_prepared_metadata(protocol_version): + if _metadata_id_negotiated(protocol_version, protocol_features): self.result_metadata_id = read_binary_string(f) else: self.result_metadata_id = None diff --git a/cassandra/protocol_features.py b/cassandra/protocol_features.py index 1bad379208..7165117e80 100644 --- a/cassandra/protocol_features.py +++ b/cassandra/protocol_features.py @@ -10,6 +10,7 @@ LWT_OPTIMIZATION_META_BIT_MASK = "LWT_OPTIMIZATION_META_BIT_MASK" RATE_LIMIT_ERROR_EXTENSION = "SCYLLA_RATE_LIMIT_ERROR" TABLETS_ROUTING_V1 = "TABLETS_ROUTING_V1" +USE_METADATA_ID = "SCYLLA_USE_METADATA_ID" class ProtocolFeatures(object): rate_limit_error = None @@ -17,15 +18,18 @@ class ProtocolFeatures(object): sharding_info = None tablets_routing_v1 = False lwt_info = None + use_metadata_id = False # Keyword-only so that independently developed protocol extensions can add # new fields without conflicting over positional-argument order. - def __init__(self, *, rate_limit_error=None, shard_id=0, sharding_info=None, tablets_routing_v1=False, lwt_info=None): + def __init__(self, *, rate_limit_error=None, shard_id=0, sharding_info=None, tablets_routing_v1=False, lwt_info=None, + use_metadata_id=False): self.rate_limit_error = rate_limit_error self.shard_id = shard_id self.sharding_info = sharding_info self.tablets_routing_v1 = tablets_routing_v1 self.lwt_info = lwt_info + self.use_metadata_id = use_metadata_id @staticmethod def parse_from_supported(supported): @@ -33,8 +37,10 @@ def parse_from_supported(supported): shard_id, sharding_info = ProtocolFeatures.parse_sharding_info(supported) tablets_routing_v1 = ProtocolFeatures.parse_tablets_info(supported) lwt_info = ProtocolFeatures.parse_lwt_info(supported) + use_metadata_id = ProtocolFeatures.parse_use_metadata_id(supported) return ProtocolFeatures(rate_limit_error=rate_limit_error, shard_id=shard_id, sharding_info=sharding_info, - tablets_routing_v1=tablets_routing_v1, lwt_info=lwt_info) + tablets_routing_v1=tablets_routing_v1, lwt_info=lwt_info, + use_metadata_id=use_metadata_id) @staticmethod def maybe_parse_rate_limit_error(supported): @@ -60,6 +66,8 @@ def add_startup_options(self, options): options[TABLETS_ROUTING_V1] = "" if self.lwt_info is not None: options[LWT_ADD_METADATA_MARK] = str(self.lwt_info.lwt_meta_bit_mask) + if self.use_metadata_id: + options[USE_METADATA_ID] = "" @staticmethod def parse_sharding_info(options): @@ -84,6 +92,11 @@ def parse_sharding_info(options): def parse_tablets_info(options): return TABLETS_ROUTING_V1 in options + @staticmethod + def parse_use_metadata_id(options): + """Return True if the ``SCYLLA_USE_METADATA_ID`` extension is advertised in ``options``.""" + return USE_METADATA_ID in options + @staticmethod def parse_lwt_info(options): value_list = options.get(LWT_ADD_METADATA_MARK, [None]) diff --git a/cassandra/query.py b/cassandra/query.py index 6c6878fdb4..39b9fdb0ad 100644 --- a/cassandra/query.py +++ b/cassandra/query.py @@ -451,13 +451,16 @@ class PreparedStatement(object): protocol_version = None query_id = None query_string = None - result_metadata = None - result_metadata_id = None + _result_metadata_and_id = (None, None) column_encryption_policy = None routing_key_indexes = None _routing_key_index_set = None serial_consistency_level = None # TODO never used? _is_lwt = False + # Set once we've logged the "new metadata id without column metadata" anomaly + # for this statement, to avoid logging it on every execute while a misbehaving + # server keeps returning it. Re-armed whenever the metadata is updated. + _warned_missing_column_metadata = False def __init__(self, column_metadata, query_id, routing_key_indexes, query, keyspace, protocol_version, result_metadata, result_metadata_id, @@ -468,12 +471,57 @@ def __init__(self, column_metadata, query_id, routing_key_indexes, query, self.query_string = query self.keyspace = keyspace self.protocol_version = protocol_version - self.result_metadata = result_metadata - self.result_metadata_id = result_metadata_id + self._result_metadata_and_id = (result_metadata, result_metadata_id) self.column_encryption_policy = column_encryption_policy self.is_idempotent = False self._is_lwt = is_lwt + @property + def result_metadata_and_id(self): + """ + The cached result metadata and its metadata id as one immutable + ``(result_metadata, result_metadata_id)`` pair. + + Read this property when both values are needed together: the tuple is + replaced atomically by :meth:`update_result_metadata`, so a single read + can never observe the metadata of one schema version paired with the + metadata id of another. + """ + return self._result_metadata_and_id + + @property + def result_metadata(self): + """ + Cached result metadata (column definitions) from PREPARE. Read-only: + :meth:`update_result_metadata` is the only way to replace it, so it can + never be assigned separately from the id it belongs to. + """ + return self._result_metadata_and_id[0] + + @property + def result_metadata_id(self): + """ + Cached result metadata id (hash) from PREPARE. Read-only: + :meth:`update_result_metadata` is the only way to replace it, so it can + never be assigned separately from the metadata it describes. + """ + return self._result_metadata_and_id[1] + + def update_result_metadata(self, result_metadata, result_metadata_id): + """ + Replace the cached result metadata and metadata id together, in a single + atomic attribute store. Response callbacks may update a statement while + request threads read it; updating the pair in one step (rather than the + two fields separately) prevents a reader from pairing a fresh metadata id + with stale metadata — a state in which the server would skip sending + metadata and rows would be decoded against the wrong columns. + + Also re-arms :attr:`_warned_missing_column_metadata`, so an anomaly that + recurs after the metadata was recovered is logged again. + """ + self._result_metadata_and_id = (result_metadata, result_metadata_id) + self._warned_missing_column_metadata = False + @classmethod def from_message(cls, query_id, column_metadata, pk_indexes, cluster_metadata, query, prepared_keyspace, protocol_version, result_metadata, diff --git a/docs/scylla-specific.rst b/docs/scylla-specific.rst index 4b28781f1c..4f61846b4c 100644 --- a/docs/scylla-specific.rst +++ b/docs/scylla-specific.rst @@ -156,3 +156,55 @@ https://github.com/scylladb/scylladb/blob/master/docs/dev/protocol-extensions.md Details on the sending tablet information to the drivers https://github.com/scylladb/scylladb/blob/master/docs/dev/protocol-extensions.md#sending-tablet-info-to-the-drivers + + +Prepared Statement Metadata Caching (``SCYLLA_USE_METADATA_ID``) +---------------------------------------------------------------- + +When the ``SCYLLA_USE_METADATA_ID`` extension is negotiated, the driver requests the +server to skip sending full result metadata with each prepared SELECT's EXECUTE +response (the ``skip_meta`` optimization), relying instead on the metadata cached +from the initial ``PREPARE`` call. Without change detection this would be unsafe: if +the table schema changes after a statement is prepared (e.g., a column is added, +removed, or its type is altered), the cached metadata becomes stale — leading to +decoding errors or incorrect data. + +ScyllaDB solves this by backporting the ``metadata_id`` mechanism from CQL native +protocol v5 as a v4 extension: ``SCYLLA_USE_METADATA_ID``. When this extension is +negotiated, the server includes a hash of the result metadata in the ``PREPARE`` +response. The driver sends this hash back with every ``EXECUTE`` request. If the +schema has changed, the server sets the ``METADATA_CHANGED`` flag and returns the +new metadata hash together with the updated column definitions. The driver +automatically updates its cache and uses the new metadata to decode the current +response — all transparently, with no application code change required. + +**Behaviour summary:** + +- Automatically negotiated at connection time when the ScyllaDB node supports it. +- ``skip_meta`` is enabled (metadata omitted from EXECUTE responses) only when it + is safe: the prepared statement must carry both a ``result_metadata_id`` and + usable cached result metadata from PREPARE, *and* the connection serving the + request must have negotiated ``SCYLLA_USE_METADATA_ID`` — decided per + connection when the request is serialized. +- Plain CQL v5 connections are unaffected: the metadata id is part of the native + v5 EXECUTE frame layout and is still sent, but the driver does not request + skip-metadata there, so such connections keep receiving full result metadata. +- When a schema change is detected by the server, the driver refreshes both the + cached column metadata and the metadata hash for that prepared statement so that + all subsequent executions benefit immediately. +- Statements prepared before the extension was negotiated (e.g., during a rolling + upgrade) start without a metadata hash, but acquire one automatically: on their + first execution over a connection with the extension, the driver sends an empty + hash, the server detects the mismatch and responds with the current hash and + full metadata, and the driver caches both. Subsequent executions get the + ``skip_meta`` optimization — no re-prepare or client restart is needed. + +**Current scope:** the optimization applies to any prepared statement that has +non-empty cached result columns — in practice, SELECT queries. +UPDATE/INSERT/DELETE statements naturally return no result columns, so +their ``result_metadata`` is always empty and ``skip_meta`` is never set for +them. There is no code-level restriction to SELECT; the behaviour follows +directly from the data. + +For full protocol details see the ScyllaDB CQL protocol extensions documentation: +https://github.com/scylladb/scylladb/blob/master/docs/dev/protocol-extensions.md From b8b714ca31782f2f527c108b10b92538887788c0 Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Tue, 21 Jul 2026 15:26:00 +0200 Subject: [PATCH 109/200] DRIVER-153: tests for SCYLLA_USE_METADATA_ID extension Unit tests for the extension across its layers: test_protocol_features.py: SCYLLA_USE_METADATA_ID parsed from SUPPORTED and echoed in STARTUP options; absent by default. test_protocol.py (wire format): - metadata-id field written on v4 iff the connection negotiated the extension, with the exact bytes asserted; empty sentinel (b'') when the statement has no id, on both the extension path (v4) and the v5 native path (previously a TypeError); - _SKIP_METADATA_FLAG written when skip_meta is requested and the SCYLLA_USE_METADATA_ID extension is negotiated (v4 or v5), and NOT set on a native v5 connection without the extension (the id field is still written there, but the driver does not request skip); also suppressed - together with the id field - on a v4 connection without the extension, even when the statement carries an id; - PREPARED response decoding reads result_metadata_id iff the extension was negotiated (or v5); METADATA_CHANGED/NO_METADATA flag handling. test_query.py: PreparedStatement stores the (result_metadata, result_metadata_id) pair atomically - constructor, update_result_metadata, and the backwards-compatible single-attribute setters all replace the pair as one unit, and previously-taken snapshots stay internally consistent. test_response_future.py: - _create_response_future builds ExecuteMessage from a single pair snapshot: skip_meta only with both an id and usable cached metadata; disabled for id-less statements, NO_METADATA/LWT statements (result_metadata None) and zero-column statements (result_metadata []), while the id still rides on the message; - _query sends the message exactly as constructed (no per-connection mutation - regression test for the speculative-execution race) and decodes a skip_meta response against the metadata snapshotted when the message was built, not a later read of the statement cache (regression for a concurrent METADATA_CHANGED racing the send); - _set_result METADATA_CHANGED path replaces the cached pair atomically; a response with a new id but no column metadata (empty or absent) is ignored with a warning, leaving the cached pair unchanged - adopting the id alone would poison the cache with a stale-metadata/current-id pair the server would never refresh; - _execute_after_prepare refreshes the pair from exactly what the reprepare response carries, including the id, and no longer keeps the previous id when the response has none (@dkropachev: doing so risked pairing a stale id with metadata from a different schema version - test_execute_after_prepare_no_metadata_id_in_response_clears_id); - a statement with valid cached metadata+id must still get skip_meta=False when continuous_paging_options is set (@dkropachev: Connection.process_msg hardcodes result_metadata=None for paging-session pages after the first, so a skip_meta response would crash decoding them - test_create_execute_message_continuous_paging_disables_skip_meta). tests/integration/standard/test_scylla_metadata_id.py: live-server coverage against a real Scylla node via CCM, closing the one gap unit tests can't - whether Scylla actually treats the empty result_metadata_id sentinel as a mismatch rather than a protocol error. Confirms extension negotiation, the normal METADATA_CHANGED-after-ALTER-TABLE path, and the sentinel round trip: a statement forced back to result_metadata_id=None (simulating one prepared before the extension was known, e.g. mid rolling-upgrade) executes without error and comes back with a fresh id. Mirrors the equivalent live test already merged in the Java driver (scylladb/java-driver#758, should_handle_empty_metadata_id_when_executing_statement_when_supported). Run locally against Scylla 2026.1.9 via CCM; see PR description for setup and log excerpt. --- cassandra/cluster.py | 2 +- .../standard/test_prepared_statements.py | 10 +- .../standard/test_scylla_metadata_id.py | 166 ++++++ tests/unit/test_protocol.py | 257 ++++++++- tests/unit/test_protocol_features.py | 35 ++ tests/unit/test_query.py | 52 ++ tests/unit/test_response_future.py | 504 +++++++++++++++++- 7 files changed, 1014 insertions(+), 12 deletions(-) create mode 100644 tests/integration/standard/test_scylla_metadata_id.py diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 87d74865a2..88c8d2707a 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -4736,7 +4736,7 @@ class ResponseFuture(object): _host = None _control_connection_query_attempted = False _TABLET_ROUTING_CTYPE = None - _bound_result_metadata = [] + _bound_result_metadata = None _warned_timeout = False diff --git a/tests/integration/standard/test_prepared_statements.py b/tests/integration/standard/test_prepared_statements.py index 37f93c94c6..98faf6a5bc 100644 --- a/tests/integration/standard/test_prepared_statements.py +++ b/tests/integration/standard/test_prepared_statements.py @@ -614,13 +614,15 @@ def _test_updated_conditional(self, session, value): prepared_statement = session.prepare( "INSERT INTO {}(a, b, d) VALUES " "(?, ? , ?) IF NOT EXISTS".format(self.table_name)) - first_id = prepared_statement.result_metadata_id - LOG.debug('initial result_metadata_id: {}'.format(first_id)) + LOG.debug('initial result_metadata_id: {}'.format(prepared_statement.result_metadata_id)) + # The cached (result_metadata, result_metadata_id) pair is not asserted on: + # a METADATA_CHANGED response refreshes it for a conditional statement like + # for any other, so its contents are the server's business. What must hold + # is that each result is decoded against the metadata describing it, whether + # the conditional update applied (narrow shape) or not (whole row). def check_result_and_metadata(expected): assert session.execute(prepared_statement, (value, value, value)).one() == expected - assert prepared_statement.result_metadata_id == first_id - assert prepared_statement.result_metadata is None # Successful conditional update check_result_and_metadata((True,)) diff --git a/tests/integration/standard/test_scylla_metadata_id.py b/tests/integration/standard/test_scylla_metadata_id.py new file mode 100644 index 0000000000..24511bee3a --- /dev/null +++ b/tests/integration/standard/test_scylla_metadata_id.py @@ -0,0 +1,166 @@ +# Copyright 2026 ScyllaDB, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest +from unittest.mock import patch + +import pytest + +from cassandra.cluster import ResponseFuture +from tests.integration import use_singledc, SCYLLA_VERSION, BasicSharedKeyspaceUnitTestCase, \ + drop_keyspace_shutdown_cluster + +pytestmark = pytest.mark.skipif(SCYLLA_VERSION is None, reason="SCYLLA_USE_METADATA_ID is a Scylla-only protocol extension") + + +def setup_module(): + use_singledc() + + +class ScyllaMetadataIdTests(BasicSharedKeyspaceUnitTestCase): + """ + Live-server coverage for the SCYLLA_USE_METADATA_ID protocol extension (DRIVER-153). + """ + + @classmethod + def setUpClass(cls): + cls.common_setup(1) + # Skip the whole class if this Scylla build does not advertise the + # extension (e.g. a version predating scylladb#23292). Without this the + # tests below would error out instead of skipping on an unsupporting node. + try: + if not cls._negotiated_use_metadata_id(): + raise unittest.SkipTest( + "Scylla node does not advertise SCYLLA_USE_METADATA_ID") + except Exception: + # setUpClass raising means unittest never calls tearDownClass, so the + # cluster and keyspace created above are torn down here explicitly. + drop_keyspace_shutdown_cluster(cls.ks_name, cls.session, cls.cluster) + raise + + @classmethod + def _negotiated_use_metadata_id(cls): + """Whether this class's data-path connections negotiated SCYLLA_USE_METADATA_ID. + + Reads the pool's existing connection rather than borrowing one: + borrow_connection() pops a stream id that only Connection.process_msg gives + back, so borrowing without sending a message would leak it. + """ + pool = next(iter(cls.session.get_pools())) + return next(iter(pool._connections.values())).features.use_metadata_id + + def setUp(self): + self.table_name = "{}.{}".format(self.keyspace_name, self.function_table_name) + self.session.execute("CREATE TABLE {} (a int PRIMARY KEY, b int, c int)".format(self.table_name)) + self.session.execute("INSERT INTO {} (a, b, c) VALUES (1, 1, 1)".format(self.table_name)) + + def tearDown(self): + self.session.execute("DROP TABLE {}".format(self.table_name)) + + def test_extension_is_negotiated(self): + """ + Sanity check that SCYLLA_USE_METADATA_ID was actually negotiated on this + connection. Without this, the tests below could pass vacuously if + negotiation silently failed. + """ + assert self._negotiated_use_metadata_id() is True + + def test_metadata_changed_recovers_after_schema_change(self): + """ + Normal METADATA_CHANGED path: after ALTER TABLE, the next EXECUTE must + come back with a fresh result_metadata_id and updated column metadata, + picked up automatically without re-preparing. + """ + prepared = self.session.prepare("SELECT * FROM {} WHERE a = ?".format(self.table_name)) + id_before = prepared.result_metadata_id + assert id_before is not None + assert len(prepared.result_metadata) == 3 + + self.session.execute(prepared.bind((1,))) + + self.session.execute("ALTER TABLE {} ADD d int".format(self.table_name)) + self.session.execute(prepared.bind((1,))) + + assert prepared.result_metadata_id is not None + assert prepared.result_metadata_id != id_before + assert len(prepared.result_metadata) == 4 + + def test_empty_sentinel_id_triggers_metadata_changed(self): + """ + Statements prepared before the extension was negotiated (e.g. mid rolling + upgrade) start with result_metadata_id=None and must send the empty b'' + sentinel on their first EXECUTE. This must not be treated as a protocol + error by the server: it must be treated as a mismatch, causing Scylla to + respond with METADATA_CHANGED (fresh id + full metadata), which the + driver then caches. + """ + prepared = self.session.prepare("SELECT * FROM {} WHERE a = ?".format(self.table_name)) + assert prepared.result_metadata_id is not None + + # Simulate "prepared before the extension was known" by dropping the + # cached id while keeping the cached metadata (mirrors the java-driver's + # should_handle_empty_metadata_id_when_executing_statement_when_supported). + prepared.update_result_metadata(prepared.result_metadata, None) + assert prepared.result_metadata_id is None + + # The table was not altered, so the statement is still valid server-side and + # nothing should re-prepare it. Spying on _reprepare keeps this test honest: + # if the id came back via an UNPREPARED/reprepare round trip instead, the + # METADATA_CHANGED-on-ROWS path in ResponseFuture._set_result would not + # actually be under test here. + with patch.object(ResponseFuture, '_reprepare', autospec=True, + side_effect=ResponseFuture._reprepare) as reprepare_spy: + result = self.session.execute(prepared.bind((1,))) + + assert reprepare_spy.call_count == 0 + assert list(result) == [(1, 1, 1)] + assert prepared.result_metadata_id is not None + + def test_conditional_statement_metadata_is_stable_across_outcomes(self): + """ + Conditional (LWT) statements get no special handling, and this pins the + server behaviour that makes that correct. + + Cassandra returns NO_METADATA for a conditional statement at PREPARE and + then varies the result shape per execution — ``(True,)`` when applied, the + conflicting row when not — which is what PYTHON-847 is about. Scylla + instead describes the result up front as ``[applied]`` plus every column of + the row, filling nulls when the update applied. The shape does not + alternate, so the cached metadata id stays valid across both outcomes and + ``skip_meta`` is exactly as safe here as for any other statement. A schema + change still changes the id, and the driver must pick that up. + """ + prepared = self.session.prepare( + "INSERT INTO {} (a, b, c) VALUES (?, ?, ?) IF NOT EXISTS".format(self.table_name)) + id_before = prepared.result_metadata_id + assert id_before is not None + assert len(prepared.result_metadata) == 4 # [applied], a, b, c + + # a=2 is free, so the insert applies; the row columns come back null. + assert self.session.execute(prepared.bind((2, 2, 2))).one() == (True, None, None, None) + + # a=1 exists (setUp), so this one does not apply and the conflicting row is + # returned — same metadata, so the cached pair is still the right one. + assert self.session.execute(prepared.bind((1, 9, 9))).one() == (False, 1, 1, 1) + assert prepared.result_metadata_id == id_before + + self.session.execute("ALTER TABLE {} ADD d int".format(self.table_name)) + + # The result gained a column, so the server must report a new id and the + # driver must adopt it — for a conditional statement like for any other. + assert self.session.execute(prepared.bind((1, 9, 9))).one() == (False, 1, 1, 1, None) + assert prepared.result_metadata_id != id_before + assert len(prepared.result_metadata) == 5 + + assert self.session.execute(prepared.bind((3, 3, 3))).one() == (True, None, None, None, None) diff --git a/tests/unit/test_protocol.py b/tests/unit/test_protocol.py index db6c37abda..75dc69bca5 100644 --- a/tests/unit/test_protocol.py +++ b/tests/unit/test_protocol.py @@ -12,15 +12,19 @@ # See the License for the specific language governing permissions and # limitations under the License. +import io +import struct import unittest +from typing import ClassVar from unittest.mock import Mock from cassandra import ConsistencyLevel, ProtocolVersion, UnsupportedOperation from cassandra.protocol import ( - PrepareMessage, QueryMessage, ExecuteMessage, UnsupportedOperation, + PrepareMessage, QueryMessage, ExecuteMessage, BatchMessage, StartupMessage, OptionsMessage, RegisterMessage, - AuthResponseMessage, ProtocolHandler, _MessageType + AuthResponseMessage, ProtocolHandler, _MessageType, + ResultMessage, RESULT_KIND_ROWS ) from cassandra.protocol_features import ProtocolFeatures from cassandra.query import BatchType @@ -66,6 +70,253 @@ def test_execute_message(self): (b'\x00\x04',), (b'\x00\x00\x00\x01',), (b'\x00\x00',)]) + def test_execute_message_skip_meta_flag_with_extension(self): + """ + skip_meta=True must set _SKIP_METADATA_FLAG (0x02) in the flags byte when + the connection negotiated SCYLLA_USE_METADATA_ID, and the metadata id + field must be written on the wire. + """ + message = ExecuteMessage('1', [], 4, skip_meta=True, result_metadata_id=b'foo') + mock_io = Mock() + + message.send_body(mock_io, 4, ProtocolFeatures(use_metadata_id=True)) + # flags byte should be VALUES_FLAG | SKIP_METADATA_FLAG = 0x01 | 0x02 = 0x03 + self._check_calls(mock_io, [(b'\x00\x01',), (b'1',), + (b'\x00\x03',), (b'foo',), + (b'\x00\x04',), (b'\x03',), (b'\x00\x00',)]) + + def test_execute_message_skip_meta_suppressed_without_extension(self): + """ + skip_meta=True must NOT reach the wire on a pre-v5 connection that did not + negotiate SCYLLA_USE_METADATA_ID: without the metadata-id mechanism, a + schema change after PREPARE would leave the driver decoding rows with + stale cached metadata. The metadata id field must not be written either. + """ + message = ExecuteMessage('1', [], 4, skip_meta=True, result_metadata_id=b'foo') + mock_io = Mock() + + message.send_body(mock_io, 4) + # flags byte contains only VALUES_FLAG; no metadata id field + self._check_calls(mock_io, [(b'\x00\x01',), (b'1',), (b'\x00\x04',), (b'\x01',), (b'\x00\x00',)]) + + def test_execute_message_v5_native_skip_meta_not_set(self): + """ + On a native protocol v5 connection (no Scylla extension), skip_meta=True must + NOT set _SKIP_METADATA_FLAG. Upstream never emitted the flag on any version, and + this PR keeps native v5 byte-identical to upstream — enabling skip on native v5 is + a separate, out-of-scope behavior change. The metadata id field is still written + (it is part of the v5 EXECUTE frame layout), so only VALUES_FLAG is set. + """ + message = ExecuteMessage('1', [], 4, skip_meta=True) + mock_io = Mock() + + message.send_body(mock_io, 5) + # v5 wire layout: + # query_id: short(1) + b'1' + # result_metadata_id: short(0) + b'' (sentinel — None on init) + # consistency: short(4) = ONE + # flags (4-byte int): VALUES_FLAG(0x01) only — skip is NOT set on native v5 + # param count: short(0) + self._check_calls(mock_io, [ + (b'\x00\x01',), (b'1',), + (b'\x00\x00',), (b'',), + (b'\x00\x04',), + (b'\x00\x00\x00\x01',), (b'\x00\x00',), + ]) + + def test_execute_message_v5_with_extension_sets_skip_flag(self): + """ + skip is extension-driven, not version-driven: on a v5 connection that ALSO + negotiated SCYLLA_USE_METADATA_ID, skip_meta=True does set _SKIP_METADATA_FLAG. + This also confirms _SKIP_METADATA_FLAG actually reaches the wire (it was dead code + upstream) whenever the extension gates it on. + """ + message = ExecuteMessage('1', [], 4, skip_meta=True) + mock_io = Mock() + + message.send_body(mock_io, 5, ProtocolFeatures(use_metadata_id=True)) + # flags (4-byte int): VALUES_FLAG(0x01) | SKIP_METADATA_FLAG(0x02) = 0x03 + self._check_calls(mock_io, [ + (b'\x00\x01',), (b'1',), + (b'\x00\x00',), (b'',), + (b'\x00\x04',), + (b'\x00\x00\x00\x03',), (b'\x00\x00',), + ]) + + def test_execute_message_scylla_metadata_id_v4(self): + """result_metadata_id should be written on protocol v4 when the connection negotiated the Scylla extension.""" + message = ExecuteMessage('1', [], 4, result_metadata_id=b'foo') + mock_io = Mock() + + message.send_body(mock_io, 4, ProtocolFeatures(use_metadata_id=True)) + # metadata_id written before query params (same position as v5) + self._check_calls(mock_io, [(b'\x00\x01',), (b'1',), + (b'\x00\x03',), (b'foo',), + (b'\x00\x04',), (b'\x01',), (b'\x00\x00',)]) + + def test_execute_message_scylla_metadata_id_none_writes_sentinel(self): + """ + When the connection negotiated the extension but result_metadata_id is None + (e.g. LWT statement or mixed cluster), send_body must still write the field + as an empty string sentinel (\\x00\\x00) so the frame layout matches what + the server expects. + """ + message = ExecuteMessage('1', [], 4) + # result_metadata_id intentionally left as None + mock_io = Mock() + + message.send_body(mock_io, 4, ProtocolFeatures(use_metadata_id=True)) + # empty sentinel: \x00\x00 (zero-length short) + b'' (zero bytes), then normal query params + self._check_calls(mock_io, [(b'\x00\x01',), (b'1',), + (b'\x00\x00',), (b'',), + (b'\x00\x04',), (b'\x01',), (b'\x00\x00',)]) + + def test_execute_message_v5_metadata_id_none_writes_sentinel(self): + """ + On protocol v5, result_metadata_id is always written (uses_prepared_metadata). + When result_metadata_id is None (e.g. LWT statement or mixed cluster where the + statement was prepared before the extension was active), send_body must write an + empty sentinel instead of crashing with TypeError. + """ + message = ExecuteMessage('1', [], 4) + # result_metadata_id intentionally left as None; use_metadata_id stays False (v5 native path) + mock_io = Mock() + + message.send_body(mock_io, 5) + # v5 always writes metadata_id: None → empty sentinel \x00\x00 + b'', then query params + # v5 uses 4-byte flags: VALUES_FLAG = \x00\x00\x00\x01 + self._check_calls(mock_io, [(b'\x00\x01',), (b'1',), + (b'\x00\x00',), (b'',), + (b'\x00\x04',), + (b'\x00\x00\x00\x01',), (b'\x00\x00',)]) + + def test_recv_results_prepared_scylla_extension_reads_metadata_id(self): + """ + When use_metadata_id is True (Scylla extension), result_metadata_id must be + read from the PREPARE response even for protocol v4. + """ + # Build a minimal valid PREPARE response binary (no bind/result columns): + # query_id: short(2) + b'ab' + # result_metadata_id: short(3) + b'xyz' <-- only present when extension active + # prepared flags: int(1) = global_tables_spec + # colcount: int(0) + # num_pk_indexes: int(0) + # ksname: short(2) + b'ks' + # cfname: short(2) + b'tb' + # result flags: int(4) = no_metadata + # result colcount: int(0) + buf = io.BytesIO( + struct.pack('>H', 2) + b'ab' # query_id + + struct.pack('>H', 3) + b'xyz' # result_metadata_id + + struct.pack('>i', 1) # prepared flags: global_tables_spec + + struct.pack('>i', 0) # colcount = 0 + + struct.pack('>i', 0) # num_pk_indexes = 0 + + struct.pack('>H', 2) + b'ks' # ksname + + struct.pack('>H', 2) + b'tb' # cfname + + struct.pack('>i', 4) # result flags: no_metadata + + struct.pack('>i', 0) # result colcount = 0 + ) + + features_with_extension = ProtocolFeatures(use_metadata_id=True) + msg = ResultMessage(kind=4) # RESULT_KIND_PREPARED = 4 + msg.recv_results_prepared(buf, protocol_version=4, + protocol_features=features_with_extension, + user_type_map={}) + assert msg.query_id == b'ab' + assert msg.result_metadata_id == b'xyz' + + def test_recv_results_prepared_no_extension_skips_metadata_id(self): + """ + Without use_metadata_id, result_metadata_id must NOT be read on protocol v4. + The buffer must NOT contain a metadata_id field. + """ + buf = io.BytesIO( + struct.pack('>H', 2) + b'ab' # query_id + # no result_metadata_id + + struct.pack('>i', 1) # prepared flags: global_tables_spec + + struct.pack('>i', 0) # colcount = 0 + + struct.pack('>i', 0) # num_pk_indexes = 0 + + struct.pack('>H', 2) + b'ks' # ksname + + struct.pack('>H', 2) + b'tb' # cfname + + struct.pack('>i', 4) # result flags: no_metadata + + struct.pack('>i', 0) # result colcount = 0 + ) + + features_without_extension = ProtocolFeatures(use_metadata_id=False) + msg = ResultMessage(kind=4) + msg.recv_results_prepared(buf, protocol_version=4, + protocol_features=features_without_extension, + user_type_map={}) + assert msg.query_id == b'ab' + assert msg.result_metadata_id is None + + def test_recv_results_prepared_v5_reads_metadata_id(self): + """ + On protocol v5, ProtocolVersion.uses_prepared_metadata() is True, so + result_metadata_id must be read from the PREPARE response even when + use_metadata_id is False (native v5 path, not the Scylla extension). + """ + buf = io.BytesIO( + struct.pack('>H', 2) + b'ab' # query_id + + struct.pack('>H', 3) + b'xyz' # result_metadata_id (always present on v5) + + struct.pack('>i', 1) # prepared flags: global_tables_spec + + struct.pack('>i', 0) # colcount = 0 + + struct.pack('>i', 0) # num_pk_indexes = 0 + + struct.pack('>H', 2) + b'ks' # ksname + + struct.pack('>H', 2) + b'tb' # cfname + + struct.pack('>i', 4) # result flags: no_metadata + + struct.pack('>i', 0) # result colcount = 0 + ) + + features_no_extension = ProtocolFeatures(use_metadata_id=False) + msg = ResultMessage(kind=4) # RESULT_KIND_PREPARED = 4 + msg.recv_results_prepared(buf, protocol_version=5, + protocol_features=features_no_extension, + user_type_map={}) + assert msg.query_id == b'ab' + assert msg.result_metadata_id == b'xyz' + + def test_recv_results_metadata_reads_metadata_id_on_change(self): + """ + When _METADATA_ID_FLAG (0x0008) is set in a ROWS result, + recv_results_metadata must read and store the new result_metadata_id + sent by the server (METADATA_CHANGED signal), and still populate + column_metadata normally. + """ + # Wire layout for a ROWS result with METADATA_CHANGED: + # flags: int(0x0008) = _METADATA_ID_FLAG + # colcount: int(0) + # result_metadata_id: short(4) + b'new1' + # (no columns — colcount=0 — to keep the buffer minimal) + buf = io.BytesIO( + struct.pack('>i', 0x0008) # flags: METADATA_ID_FLAG + + struct.pack('>i', 0) # colcount = 0 + + struct.pack('>H', 4) + b'new1' # result_metadata_id = b'new1' + ) + msg = ResultMessage(kind=RESULT_KIND_ROWS) + msg.recv_results_metadata(buf, user_type_map={}) + assert msg.result_metadata_id == b'new1' + assert msg.column_metadata == [] + + def test_recv_results_metadata_no_metadata_flag_skips_metadata_id(self): + """ + When _NO_METADATA_FLAG (0x0004) is set, recv_results_metadata returns + early and must NOT read or set result_metadata_id, even if the caller + mistakenly sets _METADATA_ID_FLAG alongside it. + """ + # flags = _NO_METADATA_FLAG (0x0004), colcount = 0 + buf = io.BytesIO( + struct.pack('>i', 0x0004) # flags: NO_METADATA + + struct.pack('>i', 0) # colcount = 0 + ) + msg = ResultMessage(kind=RESULT_KIND_ROWS) + msg.recv_results_metadata(buf, user_type_map={}) + # recv_results_metadata returns early on NO_METADATA; result_metadata_id + # must never be set as an instance attribute (it is not a class default). + # column_metadata is a class attribute defaulting to None and must remain so. + assert not hasattr(msg, 'result_metadata_id') + assert msg.column_metadata is None + def test_query_message(self): """ Test to check the appropriate calls are made @@ -237,7 +488,7 @@ class FrameByteIdentityTest(unittest.TestCase): The expected frames below were captured from the pre-change encoder. """ - EXPECTED_FRAMES = { + EXPECTED_FRAMES: ClassVar[dict] = { 'startup_v4': '0400000701000000160001000b43514c5f56455253494f4e0005332e342e35', 'options_v4': '040000070500000000', 'register_v4': '040000070b000000220002000f544f504f4c4f47595f4348414e4745000d5354415455535f4348414e4745', diff --git a/tests/unit/test_protocol_features.py b/tests/unit/test_protocol_features.py index 895c384f7e..387583680b 100644 --- a/tests/unit/test_protocol_features.py +++ b/tests/unit/test_protocol_features.py @@ -22,3 +22,38 @@ class OptionsHolder(object): assert protocol_features.rate_limit_error == 123 assert protocol_features.shard_id == 0 assert protocol_features.sharding_info is None + + def test_use_metadata_id_parsing(self): + """ + Test that SCYLLA_USE_METADATA_ID is parsed from SUPPORTED options. + """ + options = {'SCYLLA_USE_METADATA_ID': ['']} + protocol_features = ProtocolFeatures.parse_from_supported(options) + assert protocol_features.use_metadata_id is True + + def test_use_metadata_id_missing(self): + """ + Test that use_metadata_id is False when SCYLLA_USE_METADATA_ID is absent. + """ + options = {'SCYLLA_RATE_LIMIT_ERROR': ['ERROR_CODE=1']} + protocol_features = ProtocolFeatures.parse_from_supported(options) + assert protocol_features.use_metadata_id is False + + def test_use_metadata_id_startup_options(self): + """ + Test that SCYLLA_USE_METADATA_ID is included in STARTUP options when negotiated. + """ + options = {'SCYLLA_USE_METADATA_ID': ['']} + protocol_features = ProtocolFeatures.parse_from_supported(options) + startup = {} + protocol_features.add_startup_options(startup) + assert 'SCYLLA_USE_METADATA_ID' in startup + + def test_use_metadata_id_not_in_startup_when_not_negotiated(self): + """ + Test that SCYLLA_USE_METADATA_ID is NOT included in STARTUP when not negotiated. + """ + protocol_features = ProtocolFeatures.parse_from_supported({}) + startup = {} + protocol_features.add_startup_options(startup) + assert 'SCYLLA_USE_METADATA_ID' not in startup diff --git a/tests/unit/test_query.py b/tests/unit/test_query.py index 6b0ebe690e..1bbe069667 100644 --- a/tests/unit/test_query.py +++ b/tests/unit/test_query.py @@ -14,6 +14,8 @@ import unittest +import pytest + from cassandra.query import BatchStatement, PreparedStatement, SimpleStatement @@ -115,3 +117,53 @@ def is_lwt(self): batch_with_simple = BatchStatement() batch_with_simple.add(LwtSimpleStatement()) assert batch_with_simple.is_lwt() is True + + +class PreparedStatementMetadataPairTest(unittest.TestCase): + """ + result_metadata and result_metadata_id are stored as one tuple replaced in a + single attribute assignment: response callbacks update a statement while + request threads read it, and a torn pair (fresh id + stale metadata) would + make the server skip sending metadata while rows are decoded against the + wrong columns. + """ + + @staticmethod + def _make_statement(result_metadata, result_metadata_id): + return PreparedStatement( + column_metadata=[], query_id=b'qid', routing_key_indexes=None, + query="SELECT * FROM foo", keyspace='ks', protocol_version=4, + result_metadata=result_metadata, result_metadata_id=result_metadata_id) + + def test_constructor_sets_pair(self): + meta = [('ks', 'tb', 'col', None)] + ps = self._make_statement(meta, b'hash') + assert ps.result_metadata is meta + assert ps.result_metadata_id == b'hash' + assert ps.result_metadata_and_id == (meta, b'hash') + + def test_update_replaces_pair_atomically(self): + ps = self._make_statement([('ks', 'tb', 'old', None)], b'old') + snapshot_before = ps.result_metadata_and_id + + new_meta = [('ks', 'tb', 'new', None)] + ps.update_result_metadata(new_meta, b'new') + + # a snapshot taken before the update stays internally consistent + assert snapshot_before == ([('ks', 'tb', 'old', None)], b'old') + assert ps.result_metadata_and_id == (new_meta, b'new') + + def test_halves_of_the_pair_cannot_be_assigned_individually(self): + # Assigning one half alone would leave the other stale, which is exactly + # the torn state update_result_metadata() exists to prevent, so neither + # attribute is writable. + meta = [('ks', 'tb', 'col', None)] + ps = self._make_statement(meta, b'hash') + + with pytest.raises(AttributeError): + ps.result_metadata_id = b'other' + + with pytest.raises(AttributeError): + ps.result_metadata = [] + + assert ps.result_metadata_and_id == (meta, b'hash') diff --git a/tests/unit/test_response_future.py b/tests/unit/test_response_future.py index 9673b0d634..cf1194a91f 100644 --- a/tests/unit/test_response_future.py +++ b/tests/unit/test_response_future.py @@ -23,6 +23,7 @@ from cassandra.connection import Connection, ConnectionException from cassandra.protocol import (ReadTimeoutErrorMessage, WriteTimeoutErrorMessage, UnavailableErrorMessage, ResultMessage, QueryMessage, + ExecuteMessage, OverloadedErrorMessage, IsBootstrappingErrorMessage, PreparedQueryNotFound, PrepareMessage, ServerError, RESULT_KIND_ROWS, RESULT_KIND_SET_KEYSPACE, @@ -30,7 +31,7 @@ ProtocolHandler) from cassandra.policies import RetryPolicy, ExponentialBackoffRetryPolicy from cassandra.pool import NoConnectionsAvailable -from cassandra.query import SimpleStatement +from cassandra.query import SimpleStatement, PreparedStatement, BoundStatement from tests.util import assertEqual, assertIsInstance import pytest @@ -911,7 +912,7 @@ def test_repeat_orig_query_after_succesful_reprepare(self): response = Mock(spec=ResultMessage, kind=RESULT_KIND_PREPARED, - result_metadata_id='foo') + result_metadata_id=b'foo') response.results = (None, None, None, None, None) response.query_id = query_id @@ -919,11 +920,83 @@ def test_repeat_orig_query_after_succesful_reprepare(self): rf._execute_after_prepare('host', None, None, response) rf._query.assert_called_once_with('host') - rf.prepared_statement = Mock() - rf.prepared_statement.query_id = query_id + rf.prepared_statement = PreparedStatement( + column_metadata=[], query_id=query_id, routing_key_indexes=None, + query="SELECT * FROM foo", keyspace='ks', protocol_version=4, + result_metadata=[], result_metadata_id=None) rf._query = Mock(return_value=True) rf._execute_after_prepare('host', None, None, response) rf._query.assert_called_once_with('host') + assert rf.prepared_statement.result_metadata_id == b'foo' + + def test_execute_after_prepare_updates_result_metadata_id(self): + """ + After a PreparedQueryNotFound triggers a reprepare, _execute_after_prepare + must update both prepared_statement.result_metadata and + prepared_statement.result_metadata_id when the PREPARE response carries a + new metadata id. Deleting those update lines must break this test. + """ + query_id = b'reprepare_qid' + session = self.make_session() + rf = self.make_response_future(session) + + new_meta = [('ks', 'tb', 'new_col', Mock())] + response = Mock(spec=ResultMessage, + kind=RESULT_KIND_PREPARED, + result_metadata_id=b'new_hash', + column_metadata=new_meta) + response.query_id = query_id + + rf.prepared_statement = self._make_prepared_statement( + [('ks', 'tb', 'old_col', Mock())], b'old_hash', query_id=query_id) + # Pretend the anomaly warning already fired for this statement. + rf.prepared_statement._warned_missing_column_metadata = True + + rf._query = Mock(return_value=True) + rf._execute_after_prepare('host', None, None, response) + + # Both metadata fields must be refreshed from the reprepare response. + assert rf.prepared_statement.result_metadata is new_meta + assert rf.prepared_statement.result_metadata_id == b'new_hash' + assert rf.prepared_statement.result_metadata_and_id == (new_meta, b'new_hash') + # Recovering the metadata re-arms the anomaly warning, on this path too. + assert rf.prepared_statement._warned_missing_column_metadata is False + rf._query.assert_called_once_with('host') + + def test_execute_after_prepare_no_metadata_id_in_response_clears_id(self): + """ + When the PREPARE response does not carry a result_metadata_id (e.g. the + extension is not active on the reprepare connection), _execute_after_prepare + must clear the cached result_metadata_id rather than keep the previous one: + carrying it forward could pair a stale id with the freshly reprepared column + metadata (e.g. if the schema changed and reverted between the two PREPAREs, + the old id could become valid again for the current schema while paired + locally with an intermediate schema's metadata, with no server-side mismatch + to catch it). Clearing it instead lets the next id-aware execute re-acquire a + correctly paired id via the same b'' sentinel / METADATA_CHANGED self-healing + path a never-prepared statement uses. + """ + query_id = b'reprepare_qid2' + session = self.make_session() + rf = self.make_response_future(session) + + new_meta = [('ks', 'tb', 'col', Mock())] + response = Mock(spec=ResultMessage, + kind=RESULT_KIND_PREPARED, + result_metadata_id=None, + column_metadata=new_meta) + response.query_id = query_id + + rf.prepared_statement = self._make_prepared_statement( + [('ks', 'tb', 'old_col', Mock())], b'old_hash', query_id=query_id) + + rf._query = Mock(return_value=True) + rf._execute_after_prepare('host', None, None, response) + + # result_metadata is refreshed (always); result_metadata_id is cleared, not + # carried forward from the old pair. + assert rf.prepared_statement.result_metadata is new_meta + assert rf.prepared_statement.result_metadata_id is None def test_timeout_does_not_release_stream_id(self): """ @@ -1008,3 +1081,426 @@ def test_single_host_query_plan_exhausted_after_one_retry(self): # Instead, it should set a NoHostAvailable exception assert rf._final_exception is not None assert isinstance(rf._final_exception, NoHostAvailable) + + # ------------------------------------------------------------------------- + # Helpers for SCYLLA_USE_METADATA_ID tests + # ------------------------------------------------------------------------- + + def _make_rows_response(self, result_metadata_id=None, column_metadata=None): + """ + Return a real ResultMessage(kind=RESULT_KIND_ROWS) with all attributes + that _set_result accesses pre-set, so it passes isinstance checks and + doesn't trigger unexpected code paths. + """ + response = ResultMessage(kind=RESULT_KIND_ROWS) + response.paging_state = None + response.column_names = ['col'] + response.parsed_rows = [] + response.column_types = [] + response.column_metadata = column_metadata + response.result_metadata_id = result_metadata_id + response.trace_id = None + response.warnings = None + response.custom_payload = None + return response + + def _make_prepared_statement(self, result_metadata, result_metadata_id, query_id=b'qid'): + return PreparedStatement( + column_metadata=[], query_id=query_id, routing_key_indexes=None, + query="SELECT * FROM foo", keyspace='ks', protocol_version=4, + result_metadata=result_metadata, result_metadata_id=result_metadata_id) + + def _make_execute_response_future(self, session, connection, prepared_statement): + """ + Return a ResponseFuture whose message is an ExecuteMessage and which + has a prepared_statement set, as _create_response_future would build it. + """ + execute_msg = ExecuteMessage(b'qid', [], ConsistencyLevel.ONE) + query = SimpleStatement("SELECT * FROM foo") + rf = ResponseFuture( + session, execute_msg, query, timeout=1, + prepared_statement=prepared_statement, + # mirror _create_response_future: snapshot the metadata paired with the id + bound_result_metadata=prepared_statement.result_metadata, + ) + pool = session._pools.get.return_value + pool.borrow_connection.return_value = (connection, 1) + return rf + + def _create_execute_future(self, prepared_statement, continuous_paging_options=None): + """ + Drive the real Session._create_response_future (with a mock session) for + a statement bound to `prepared_statement`, returning the ResponseFuture. + This exercises the ExecuteMessage construction path where skip_meta and + result_metadata_id are decided from the statement's metadata pair. + """ + session = self.make_session() + profile = session._maybe_get_execution_profile.return_value + profile.consistency_level = ConsistencyLevel.ONE + profile.serial_consistency_level = None + profile.continuous_paging_options = continuous_paging_options + profile.speculative_execution_policy = None + profile.load_balancing_policy.make_query_plan.return_value = ['ip1'] + session.default_fetch_size = 5000 + session.use_client_timestamp = False + bound = BoundStatement(prepared_statement).bind(()) + return Session._create_response_future( + session, bound, parameters=None, trace=False, custom_payload=None, timeout=1) + + # ------------------------------------------------------------------------- + # _set_result: METADATA_CHANGED update path + # ------------------------------------------------------------------------- + + def test_set_result_updates_metadata_when_metadata_changed(self): + """ + When the EXECUTE response carries a new result_metadata_id (server + detected a schema change), _set_result must update both + prepared_statement.result_metadata and prepared_statement.result_metadata_id. + """ + session = self.make_session() + pool = session._pools.get.return_value + connection = Mock(spec=Connection) + connection.protocol_version = 4 + connection.features = Mock() + connection.features.use_metadata_id = False + pool.borrow_connection.return_value = (connection, 1) + + old_meta = [('ks', 'tb', 'old_col', Mock())] + new_meta = [('ks', 'tb', 'new_col', Mock())] + ps = self._make_prepared_statement(old_meta, b'old_id') + + rf = self.make_response_future(session) + rf.prepared_statement = ps + rf.send_request() + + response = self._make_rows_response( + result_metadata_id=b'new_id', + column_metadata=new_meta, + ) + rf._set_result(None, None, None, response) + + assert ps.result_metadata is new_meta + assert ps.result_metadata_id == b'new_id' + # the pair is replaced as one unit — a snapshot can never be torn + assert ps.result_metadata_and_id == (new_meta, b'new_id') + + def test_set_result_does_not_update_metadata_when_metadata_id_absent(self): + """ + When the EXECUTE response has no result_metadata_id (normal skip-meta + path — server metadata unchanged), _set_result must leave the + prepared_statement's cached metadata untouched. + """ + session = self.make_session() + pool = session._pools.get.return_value + connection = Mock(spec=Connection) + connection.protocol_version = 4 + connection.features = Mock() + connection.features.use_metadata_id = False + pool.borrow_connection.return_value = (connection, 1) + + old_meta = [('ks', 'tb', 'col', Mock())] + ps = self._make_prepared_statement(old_meta, b'old_id') + + rf = self.make_response_future(session) + rf.prepared_statement = ps + rf.send_request() + + # result_metadata_id is None → server sent full metadata, no hash update + response = self._make_rows_response( + result_metadata_id=None, + column_metadata=old_meta, + ) + rf._set_result(None, None, None, response) + + assert ps.result_metadata is old_meta + assert ps.result_metadata_id == b'old_id' + + def test_set_result_warns_when_metadata_id_but_no_column_metadata(self): + """ + If the server sends a new result_metadata_id but no column metadata + (protocol violation), _set_result must emit a WARNING and cache + NEITHER value: adopting the new id while keeping the old metadata would + make the server skip sending metadata on subsequent executes (the ids + match) while the driver decodes with stale metadata — with no recovery. + Keeping the old pair means the next EXECUTE sends the old id, the server + detects the mismatch, and the driver recovers with full metadata. + """ + session = self.make_session() + pool = session._pools.get.return_value + connection = Mock(spec=Connection) + connection.protocol_version = 4 + connection.features = Mock() + connection.features.use_metadata_id = False + pool.borrow_connection.return_value = (connection, 1) + + old_meta = [('ks', 'tb', 'col', Mock())] + ps = self._make_prepared_statement(old_meta, b'old_id') + + rf = self.make_response_future(session) + rf.prepared_statement = ps + rf.send_request() + + # column_metadata is falsy (empty list) but result_metadata_id is set + response = self._make_rows_response( + result_metadata_id=b'new_id', + column_metadata=[], + ) + + with self.assertLogs('cassandra.cluster', level='WARNING') as log_ctx: + rf._set_result(None, None, None, response) + + assert any('result_metadata_id' in msg for msg in log_ctx.output) + # nothing is cached from the anomalous response + assert ps.result_metadata_and_id == (old_meta, b'old_id') + + def test_set_result_warns_when_metadata_id_but_column_metadata_is_none(self): + """ + Like the empty-list variant above, but column_metadata=None (attribute + absent rather than explicitly empty). Both None and [] are falsy, so + the warning branch is taken and the cached pair is left unchanged. + """ + session = self.make_session() + pool = session._pools.get.return_value + connection = Mock(spec=Connection) + connection.protocol_version = 4 + connection.features = Mock() + connection.features.use_metadata_id = False + pool.borrow_connection.return_value = (connection, 1) + + old_meta = [('ks', 'tb', 'col', Mock())] + ps = self._make_prepared_statement(old_meta, b'old_id') + + rf = self.make_response_future(session) + rf.prepared_statement = ps + rf.send_request() + + response = self._make_rows_response( + result_metadata_id=b'new_id', + column_metadata=None, + ) + + with self.assertLogs('cassandra.cluster', level='WARNING') as log_ctx: + rf._set_result(None, None, None, response) + + assert any('result_metadata_id' in msg for msg in log_ctx.output) + assert ps.result_metadata_and_id == (old_meta, b'old_id') + + def test_set_result_no_metadata_statement_adopts_metadata_changed(self): + """ + A statement whose PREPARE returned NO_METADATA for the result columns + (result_metadata None while the id is live) is not special-cased. A + METADATA_CHANGED response updates its cached pair like any other + statement's: the id describes the metadata the server sent alongside it, + and a server that later produces different result metadata must report the + id mismatch — the same contract every other statement already relies on to + avoid decoding rows against stale columns. + """ + session = self.make_session() + pool = session._pools.get.return_value + connection = Mock(spec=Connection) + connection.protocol_version = 4 + connection.features = Mock() + connection.features.use_metadata_id = True + pool.borrow_connection.return_value = (connection, 1) + + ps = self._make_prepared_statement(None, b'old_id') + + rf = self.make_response_future(session) + rf.prepared_statement = ps + rf.send_request() + + new_meta = [('ks', 'tb', '[applied]', Mock())] + response = self._make_rows_response( + result_metadata_id=b'new_id', + column_metadata=new_meta, + ) + + # Ordinary METADATA_CHANGED handling, so no anomaly warning either. + with self.assertNoLogs('cassandra.cluster', level='WARNING'): + rf._set_result(None, None, None, response) + + assert ps.result_metadata_and_id == (new_meta, b'new_id') + + def test_set_result_anomalous_metadata_id_warns_once_and_rearms(self): + """ + The anomalous-response warning (new id, no column metadata) is logged + once per prepared statement, not once per execute: a persistently + misbehaving server must not spam the log. A successful METADATA_CHANGED + in between re-arms the warning so a later recurrence is logged again. + """ + session = self.make_session() + pool = session._pools.get.return_value + connection = Mock(spec=Connection) + connection.protocol_version = 4 + connection.features = Mock() + connection.features.use_metadata_id = False + pool.borrow_connection.return_value = (connection, 1) + + old_meta = [('ks', 'tb', 'col', Mock())] + ps = self._make_prepared_statement(old_meta, b'old_id') + + rf = self.make_response_future(session) + rf.prepared_statement = ps + rf.send_request() + + anomalous = self._make_rows_response(result_metadata_id=b'new_id', column_metadata=[]) + + # First anomalous response: warns once. + with self.assertLogs('cassandra.cluster', level='WARNING') as first: + rf._set_result(None, None, None, anomalous) + assert sum('result_metadata_id' in msg for msg in first.output) == 1 + + # Second identical anomalous response: no new warning (deduped). + with self.assertNoLogs('cassandra.cluster', level='WARNING'): + rf._set_result(None, None, None, anomalous) + assert ps.result_metadata_and_id == (old_meta, b'old_id') + + # A genuine METADATA_CHANGED recovers the metadata and re-arms the warning. + new_meta = [('ks', 'tb', 'new_col', Mock())] + rf._set_result(None, None, None, + self._make_rows_response(result_metadata_id=b'new_id', column_metadata=new_meta)) + assert ps.result_metadata_and_id == (new_meta, b'new_id') + + # After recovery, the anomaly warns again. + with self.assertLogs('cassandra.cluster', level='WARNING') as after: + rf._set_result(None, None, None, anomalous) + assert sum('result_metadata_id' in msg for msg in after.output) == 1 + + def test_create_execute_message_with_metadata_and_id(self): + """ + When the prepared statement carries both a result_metadata_id and usable + cached result_metadata, _create_response_future must build the + ExecuteMessage with skip_meta=True and the metadata id attached. Whether + either actually reaches the wire is decided per connection at + serialization time (ExecuteMessage.send_body). + """ + ps = self._make_prepared_statement([('ks', 'tbl', 'col', Mock())], b'meta_hash') + + rf = self._create_execute_future(ps) + + assert rf.message.skip_meta is True + assert rf.message.result_metadata_id == b'meta_hash' + + def test_create_execute_message_without_metadata_id(self): + """ + A statement prepared before the extension was active (result_metadata_id + is None) must never request skip_meta — the driver has no hash the server + could validate the cached metadata against. + """ + ps = self._make_prepared_statement([('ks', 'tbl', 'col', Mock())], None) + + rf = self._create_execute_future(ps) + + assert rf.message.skip_meta is False + assert rf.message.result_metadata_id is None + + def test_create_execute_message_result_metadata_none(self): + """ + A statement can carry a result_metadata_id while its PREPARE response set + NO_METADATA for the result columns, leaving result_metadata as None — + Cassandra does this for conditional statements (Scylla instead describes + them up front, see the conditional-statement integration test). skip_meta + must stay off: the server would omit column definitions while the driver + has nothing cached to decode with. The id still rides on the message so + id-aware connections always send it. + """ + ps = self._make_prepared_statement(None, b'lwt_hash') + + rf = self._create_execute_future(ps) + + assert rf.message.skip_meta is False + assert rf.message.result_metadata_id == b'lwt_hash' + + def test_create_execute_message_result_metadata_empty(self): + """ + Statements returning zero result columns (plain INSERT/UPDATE/DELETE) + have result_metadata == []. Like the None case, skip_meta stays off — + there is no metadata worth skipping. + """ + ps = self._make_prepared_statement([], b'meta_hash') + + rf = self._create_execute_future(ps) + + assert rf.message.skip_meta is False + assert rf.message.result_metadata_id == b'meta_hash' + + def test_create_execute_message_continuous_paging_disables_skip_meta(self): + """ + Continuous paging sessions must never get skip_meta=True, even with a + statement that otherwise qualifies (valid cached metadata + id): + Connection.process_msg hardcodes result_metadata=None for every page + after the first (it isn't threaded through the paging session), so a + skip_meta response would leave nothing to decode page 2+ against. + """ + ps = self._make_prepared_statement([('ks', 'tbl', 'col', Mock())], b'meta_hash') + + rf = self._create_execute_future(ps, continuous_paging_options=Mock()) + + assert rf.message.skip_meta is False + assert rf.message.result_metadata_id == b'meta_hash' + + def test_query_does_not_mutate_execute_message(self): + """ + _query() must send the ExecuteMessage exactly as constructed: all + per-connection decisions (whether the id field and the skip_meta flag hit + the wire) happen at serialization time from the connection's negotiated + features. Mutating the shared message per attempt would race with + speculative executions sending the same message on another connection. + """ + session = self.make_basic_session() + session.cluster._default_load_balancing_policy.make_query_plan.return_value = ['ip1'] + session._pools.get.return_value = self.make_pool() + + connection = Mock(spec=Connection) + connection.protocol_version = 4 + connection.features = Mock() + connection.features.use_metadata_id = True + session._pools.get.return_value.borrow_connection.return_value = (connection, 1) + + ps = self._make_prepared_statement([('ks', 'tbl', 'col', Mock())], b'meta_hash') + rf = self._make_execute_response_future(session, connection, ps) + original_skip_meta = rf.message.skip_meta + original_id = rf.message.result_metadata_id + + rf.send_request() + + connection.send_msg.assert_called_once() + sent_message = connection.send_msg.call_args[0][0] + assert sent_message is rf.message + assert rf.message.skip_meta is original_skip_meta + assert rf.message.result_metadata_id is original_id + assert not hasattr(rf.message, 'use_metadata_id') + + def test_query_decodes_with_construction_snapshot_not_live_cache(self): + """ + The metadata handed to the decoder must be the snapshot taken when the message + was built (paired with the id the immutable message carries), not a fresh read of + the prepared statement's cache. Otherwise a concurrent METADATA_CHANGED landing + between construction and send could pair the message's id with a different schema + version's metadata — the torn read the atomic pair was meant to prevent. + """ + session = self.make_basic_session() + session.cluster._default_load_balancing_policy.make_query_plan.return_value = ['ip1'] + session._pools.get.return_value = self.make_pool() + + connection = Mock(spec=Connection) + connection.protocol_version = 4 + connection.features = Mock() + connection.features.use_metadata_id = True + session._pools.get.return_value.borrow_connection.return_value = (connection, 1) + + meta_v1 = [('ks', 'tbl', 'col_v1', Mock())] + ps = self._make_prepared_statement(meta_v1, b'id1') + rf = self._make_execute_response_future(session, connection, ps) + # snapshot captured at construction, independent of the live pair + assert rf._bound_result_metadata is meta_v1 + + # a concurrent METADATA_CHANGED replaces the statement's cached pair + ps.update_result_metadata([('ks', 'tbl', 'col_v2', Mock())], b'id2') + assert rf._bound_result_metadata is meta_v1 + + rf.send_request() + + connection.send_msg.assert_called_once() + # _query decodes with the construction snapshot, not the mutated cache + assert connection.send_msg.call_args.kwargs['result_metadata'] is meta_v1 From 9b5b037b722b8383b986e64b63f9130fe8667bb2 Mon Sep 17 00:00:00 2001 From: David Garcia Date: Mon, 20 Jul 2026 18:48:07 +0100 Subject: [PATCH 110/200] docs: update theme 1.9.3 Updates docs theme to 1.9.3. --- docs/pyproject.toml | 2 +- docs/uv.lock | 45 +++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/docs/pyproject.toml b/docs/pyproject.toml index 7aa0e2844b..f49bc3f520 100644 --- a/docs/pyproject.toml +++ b/docs/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ "redirects_cli~=0.1.3", "sphinx-autobuild>=2025.0.0,<2026.0.0", "sphinx-sitemap>=2.8.0,<3.0.0", - "sphinx-scylladb-theme>=1.9.2", + "sphinx-scylladb-theme>=1.9.3", "sphinx-multiversion-scylla>=0.3.2,<1.0.0", "sphinx>=9.0", "six>=1.9", diff --git a/docs/uv.lock b/docs/uv.lock index 39bced3f24..16e14fdd51 100644 --- a/docs/uv.lock +++ b/docs/uv.lock @@ -666,7 +666,7 @@ requires-dist = [ { name = "sphinx", specifier = ">=9.0" }, { name = "sphinx-autobuild", specifier = ">=2025.0.0,<2026.0.0" }, { name = "sphinx-multiversion-scylla", specifier = ">=0.3.2,<1.0.0" }, - { name = "sphinx-scylladb-theme", specifier = ">=1.9.2" }, + { name = "sphinx-scylladb-theme", specifier = ">=1.9.3" }, { name = "sphinx-sitemap", specifier = ">=2.8.0,<3.0.0" }, { name = "tornado", specifier = ">=6.5,<7.0" }, ] @@ -868,6 +868,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/fb/e496f16fa11fbe2dbdd0b5e306ede153dfed050aae4766fc89d500720dc7/sphinx_last_updated_by_git-0.3.8-py3-none-any.whl", hash = "sha256:6382c8285ac1f222483a58569b78c0371af5e55f7fbf9c01e5e8a72d6fdfa499", size = 8580, upload-time = "2024-08-11T07:15:53.244Z" }, ] +[[package]] +name = "sphinx-llm" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, + { name = "sphinx-markdown-builder" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/23/fe184bf9c6761c2cd04dc5b5456b717f626143663a3004f628636a2f7b0e/sphinx_llm-0.4.1.tar.gz", hash = "sha256:0789185dcbbecc00b5e25aa3db6342b98dad6a9def96088a9e50fa0203fda090", size = 280250, upload-time = "2026-04-02T09:09:18.881Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/2b/a54c49b42b25a6eb6039daf882f06488812c28e32734e944b2f7b6f82554/sphinx_llm-0.4.1-py3-none-any.whl", hash = "sha256:d7c8ee2a6335636b628ea2b26cd1e1dee1f0cf9c40fe51b20f201af1c05b95f5", size = 28453, upload-time = "2026-04-02T09:09:17.632Z" }, +] + +[[package]] +name = "sphinx-markdown-builder" +version = "0.6.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "sphinx" }, + { name = "tabulate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/58/0b7b9a7d071140b3705885d51932e8b62f520388c2772e4952189971727b/sphinx_markdown_builder-0.6.10.tar.gz", hash = "sha256:cd5acf88d52ea0146a712fd557404f10326dff3428a78ba928e59b1727fd4a86", size = 22688, upload-time = "2026-03-11T10:56:57.639Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/8f/9fecf3d081d5cd49eff83a17b9fef50ed741e6223ab3bb906de4ab0068f9/sphinx_markdown_builder-0.6.10-py3-none-any.whl", hash = "sha256:16d86738b9ac69fcbc86e373c31c6402c30af1fa8d98d0f62cc5f38bfe5fc26e", size = 16700, upload-time = "2026-03-11T10:56:56.135Z" }, +] + [[package]] name = "sphinx-multiversion-scylla" version = "0.3.8" @@ -894,7 +921,7 @@ wheels = [ [[package]] name = "sphinx-scylladb-theme" -version = "1.9.2" +version = "1.9.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "beautifulsoup4" }, @@ -902,14 +929,15 @@ dependencies = [ { name = "setuptools" }, { name = "sphinx-collapse" }, { name = "sphinx-copybutton" }, + { name = "sphinx-llm" }, { name = "sphinx-notfound-page" }, { name = "sphinx-substitution-extensions" }, { name = "sphinx-tabs" }, { name = "sphinxcontrib-mermaid" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/31/92/e30549be27dfdbfb3a1bf52cbc5496c190230dd2d4e7a41c8bafada8f4a2/sphinx_scylladb_theme-1.9.2.tar.gz", hash = "sha256:f4319deeefcc446779375c2d9cbdd922eaf63da092a50def74247dd2156f1274", size = 1683295, upload-time = "2026-04-14T11:07:30.662Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/43/2c6ca729655d99c69a7970efe56345d4bf345a345511ce4dc247aa5380df/sphinx_scylladb_theme-1.9.3.tar.gz", hash = "sha256:2a80252d6a5bb1ef8b61b6af47db4b7cbdec9c056b339ad4bc2cee97604603ad", size = 1691127, upload-time = "2026-07-16T16:44:51.404Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/ff/9957eef93c1b46dbbccd66cb4766d513c1061961daaa60fcfc1a78b3bc20/sphinx_scylladb_theme-1.9.2-py3-none-any.whl", hash = "sha256:1d75463151693c3b31ef48b2401aa4db18953fc515b4061c6f127182242e0280", size = 1669961, upload-time = "2026-04-14T11:07:28.944Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0e/b79e97434339b4b935535709b5b20491451e5c5de5d4b90c8cafbdcc7c2f/sphinx_scylladb_theme-1.9.3-py3-none-any.whl", hash = "sha256:2b9eb999421711deb7ca0fbd5c287c668cdaee7bd41d19abf75b140e8e6982d4", size = 1674682, upload-time = "2026-07-16T16:44:49.644Z" }, ] [[package]] @@ -1033,6 +1061,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, ] +[[package]] +name = "tabulate" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/58/8c37dea7bbf769b20d58e7ace7e5edfe65b849442b00ffcdd56be88697c6/tabulate-0.10.0.tar.gz", hash = "sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d", size = 91754, upload-time = "2026-03-04T18:55:34.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" }, +] + [[package]] name = "tornado" version = "6.5.7" From 1c9afb04ac97c6b8ac32b1490a13934e12bba82e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karol=20Bary=C5=82a?= Date: Tue, 28 Apr 2026 19:24:43 +0200 Subject: [PATCH 111/200] policies.py: Remove max reconnect attempts This was kept this way to preserve legacy behavior, but I think changing the behavior will be less of a problem than what the current behavior causes. The policy is used for reconnections (for example, reconnecting control connection). If reconnect policy finishes generation (it will do so after 64 attempts before my change), then the reconnector finish and the driver won't attempt reconnection anymore. This would be a terrible situation. --- cassandra/policies.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/cassandra/policies.py b/cassandra/policies.py index 14c79fd70e..89702e8c89 100644 --- a/cassandra/policies.py +++ b/cassandra/policies.py @@ -773,7 +773,7 @@ class ConstantReconnectionPolicy(ReconnectionPolicy): in-between each reconnection attempt. """ - def __init__(self, delay, max_attempts=64): + def __init__(self, delay, max_attempts=None): """ `delay` should be a floating point number of seconds to wait in-between each attempt. @@ -807,10 +807,7 @@ class ExponentialReconnectionPolicy(ReconnectionPolicy): trying to reconnect at exactly the same time. """ - # TODO: max_attempts is 64 to preserve legacy default behavior - # consider changing to None in major release to prevent the policy - # giving up forever - def __init__(self, base_delay, max_delay, max_attempts=64): + def __init__(self, base_delay, max_delay, max_attempts=None): """ `base_delay` and `max_delay` should be in floating point units of seconds. From 5d8ece1248d670952a55c44ced66cc06851fba23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karol=20Bary=C5=82a?= Date: Tue, 28 Apr 2026 19:58:38 +0200 Subject: [PATCH 112/200] connection.py: Rename timeout to timeout_left This better conveys what this is: not a timeut duration from config, but how much of this timeout is left right now. --- cassandra/connection.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cassandra/connection.py b/cassandra/connection.py index f238416b29..eea6a707b4 100644 --- a/cassandra/connection.py +++ b/cassandra/connection.py @@ -1903,13 +1903,13 @@ def run(self): self._raise_if_stopped() # Wait max `self._timeout` seconds for all HeartbeatFutures to complete - timeout = self._timeout + timeout_left = self._timeout start_time = time.time() for f in futures: self._raise_if_stopped() connection = f.connection try: - f.wait(timeout) + f.wait(timeout_left) # TODO: move this, along with connection locks in pool, down into Connection with connection.lock: connection.in_flight -= 1 @@ -1919,7 +1919,7 @@ def run(self): id(connection), connection.endpoint) failed_connections.append((f.connection, f.owner, e)) - timeout = self._timeout - (time.time() - start_time) + timeout_left = self._timeout - (time.time() - start_time) for connection, owner, exc in failed_connections: self._raise_if_stopped() From c6b240a635eba31026abaff41094f05416b74552 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karol=20Bary=C5=82a?= Date: Tue, 28 Apr 2026 20:00:46 +0200 Subject: [PATCH 113/200] HearbeatFuture: Use correct timeout in error message The timeout argument in `wait` tells how much we need to wait taking into consideration that we already waited for some other futures. The total wait time that this future had available to complete is different: it includes time we spent waiting for other futures. This created confusing hearbeat messages, that could even show negative wait times. I fixed it by putting both timeouts in the error message. The `timeout` parameter of `OperationTimedOut` I changed to the original timeout because I think it is more useful and relevant here. --- cassandra/connection.py | 8 ++++---- tests/unit/test_connection.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cassandra/connection.py b/cassandra/connection.py index eea6a707b4..fd7808afc5 100644 --- a/cassandra/connection.py +++ b/cassandra/connection.py @@ -1836,15 +1836,15 @@ def __init__(self, connection, owner): self._exception = Exception("Failed to send heartbeat because connection 'in_flight' exceeds threshold") self._event.set() - def wait(self, timeout): + def wait(self, timeout, original_timeout): self._event.wait(timeout) if self._event.is_set(): if self._exception: raise self._exception else: - raise OperationTimedOut("Connection heartbeat timeout after %s seconds" % (timeout,), + raise OperationTimedOut("Connection heartbeat timeout (total wait=%s seconds, this wait call=%s seconds)" % (original_timeout, timeout), self.connection.endpoint, - timeout=timeout, + timeout=original_timeout, in_flight=self.connection.in_flight) def _options_callback(self, response): @@ -1909,7 +1909,7 @@ def run(self): self._raise_if_stopped() connection = f.connection try: - f.wait(timeout_left) + f.wait(timeout_left, self._timeout) # TODO: move this, along with connection locks in pool, down into Connection with connection.lock: connection.in_flight -= 1 diff --git a/tests/unit/test_connection.py b/tests/unit/test_connection.py index 1f9a3f682c..558c9996aa 100644 --- a/tests/unit/test_connection.py +++ b/tests/unit/test_connection.py @@ -563,7 +563,7 @@ def send_msg(msg, req_id, msg_callback): connection.defunct.assert_has_calls([call(ANY)] * get_holders.call_count) exc = connection.defunct.call_args_list[0][0][0] assert isinstance(exc, OperationTimedOut) - assert exc.errors == 'Connection heartbeat timeout after 0.05 seconds' + assert exc.errors == 'Connection heartbeat timeout (total wait=0.05 seconds, this wait call=0.05 seconds)' assert exc.last_host == DefaultEndPoint('localhost') assert exc.timeout == 0.05 assert isinstance(exc.in_flight, int) From 89834817d7825551c03a99a9bbd9fc8379304c54 Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Fri, 31 Jul 2026 08:19:40 -0400 Subject: [PATCH 114/200] connection: fix heartbeat future test timeout arguments --- tests/unit/test_connection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_connection.py b/tests/unit/test_connection.py index 558c9996aa..8fdedd723f 100644 --- a/tests/unit/test_connection.py +++ b/tests/unit/test_connection.py @@ -500,7 +500,7 @@ def return_connection(conn): future = HeartbeatFuture(connection, owner) with pytest.raises(ConnectionException): - future.wait(0) + future.wait(timeout=0, original_timeout=0) owner.return_connection(connection) From d8fca1d9ef75f7359d99c30818b864a241a9e517 Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Fri, 31 Jul 2026 09:02:20 -0400 Subject: [PATCH 115/200] test: stabilize UUID1 timestamp assertions --- tests/unit/test_time_util.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/unit/test_time_util.py b/tests/unit/test_time_util.py index d87a3fe2ad..05b4349cf8 100644 --- a/tests/unit/test_time_util.py +++ b/tests/unit/test_time_util.py @@ -51,15 +51,17 @@ def test_datetime_from_ms_timestamp(self): def test_times_from_uuid1(self): node = uuid.getnode() - now = time.time() + before = time.time() u = uuid.uuid1(node, 0) + after = time.time() - t = util.unix_time_from_uuid1(u) - assert now == pytest.approx(t, abs=1e-2) + uuid_time = util.unix_time_from_uuid1(u) + # Allow for coarse platform clocks and uuid1's monotonic adjustment. + assert before - 0.1 <= uuid_time <= after + 0.1 dt = util.datetime_from_uuid1(u) - t = calendar.timegm(dt.timetuple()) + dt.microsecond / 1e6 - assert now == pytest.approx(t, abs=1e-2) + datetime_time = calendar.timegm(dt.timetuple()) + dt.microsecond / 1e6 + assert datetime_time == pytest.approx(uuid_time, abs=1e-6, rel=0) def test_uuid_from_time(self): t = time.time() From 6a7d851ad20d722a22ec37debb5e81994afd1776 Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Fri, 31 Jul 2026 09:03:54 -0400 Subject: [PATCH 116/200] test: remove Twisted initialization race --- tests/unit/io/test_twistedreactor.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/unit/io/test_twistedreactor.py b/tests/unit/io/test_twistedreactor.py index 02bac10d8e..23d9148e97 100644 --- a/tests/unit/io/test_twistedreactor.py +++ b/tests/unit/io/test_twistedreactor.py @@ -98,6 +98,7 @@ def setUp(self): self.reactor_cft_patcher = patch( 'twisted.internet.reactor.callFromThread') self.reactor_run_patcher = patch('twisted.internet.reactor.run') + self.thread_patcher = patch('cassandra.io.twistedreactor.Thread') # Patch reactor.running to False so maybe_start() always enters # the branch that spawns the reactor thread. Without this, leaked # reactor state from prior tests can cause reactor.running to be @@ -107,6 +108,9 @@ def setUp(self): 'twisted.internet.reactor.running', new=False) self.mock_reactor_cft = self.reactor_cft_patcher.start() self.mock_reactor_run = self.reactor_run_patcher.start() + self.mock_thread_class = self.thread_patcher.start() + self.mock_thread = self.mock_thread_class.return_value + self.mock_thread.is_alive.return_value = False self.reactor_running_patcher.start() self.obj_ut = twistedreactor.TwistedConnection(DefaultEndPoint('1.2.3.4'), cql_version='3.0.1') @@ -114,6 +118,7 @@ def setUp(self): def tearDown(self): self.reactor_cft_patcher.stop() self.reactor_run_patcher.stop() + self.thread_patcher.stop() self.reactor_running_patcher.stop() def test_connection_initialization(self): @@ -121,7 +126,12 @@ def test_connection_initialization(self): Verify that __init__() works correctly. """ self.mock_reactor_cft.assert_called_with(self.obj_ut.add_connection) - self.mock_reactor_run.assert_called_with(installSignalHandlers=False) + self.mock_thread_class.assert_called_once_with( + target=self.mock_reactor_run, + name="cassandra_driver_twisted_event_loop", + kwargs={'installSignalHandlers': False}) + self.assertIs(self.mock_thread.daemon, True) + self.mock_thread.start.assert_called_once_with() def test_client_connection_made(self): """ From d99dc460db7066dbafd15103198c1085b3c6623e Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Thu, 30 Jul 2026 10:09:26 +0300 Subject: [PATCH 117/200] test: fix TcpProxy close/race in test_client_routes.py NLB test helper TcpProxy.stop()/drop_connections() used to close a connection's sockets directly, from the manager thread, while that connection's own forwarder thread could still be blocked in select()/recv() on those exact file descriptors -- a classic close-under-concurrent-user race. Since fds are process-global, closing them could let the OS silently recycle the fd number into a brand new connection before the stale forwarder thread's blocked call unwound, causing it to read/write/close a socket that no longer belonged to it (observed here as unhandled "ValueError: file descriptor cannot be a negative integer (-1)" crashes in _forward_loop once a socket was closed out from under it). stop() also only ever joined the accept-loop thread, never the per-connection forwarder threads it had just closed sockets out from under. Fix, scoped entirely to this test helper (no driver code touched): - TcpProxy._connections now maps (client_sock, target_sock) -> the forwarder thread serving that pair. - stop()/drop_connections() now shut down (SHUT_RDWR) both sockets -- safe to do concurrently with a blocked select()/recv(), unlike close() -- and then join() every forwarder thread before returning. Only the forwarder thread itself ever closes its own sockets now, and only after it has fully stopped using them. - _handle_new_connection starts the forwarder thread before publishing it into _connections, so a concurrent stop()/drop_connections() can never observe (and try to join) a thread that hasn't started yet. - NLBEmulator.add_node()/remove_node() now serialize against each other via an RLock, so a new proxy/connection can never be created while another thread's remove_node() is still tearing one down. - NLBEmulator._live_addresses() (read by rr_handler(), the discovery port's round-robin accept handler, from the discovery TcpProxy's own accept-loop thread) now also snapshots self._node_proxies under the same _lock that add_node()/remove_node() mutate it under. Previously it iterated the dict unlocked, so a concurrent add_node()/remove_node() could raise "RuntimeError: dictionary changed size during iteration" on that thread. A follow-up pass over the same synchronization path (Copilot automated review on the PR, flagged low-confidence so not posted as formal review threads, but both genuine) found one more real gap and a missing test: - _shutdown_and_join_connections() joined every forwarder thread with a 5s timeout, then unconditionally popped *every* connection out of self._connections regardless of whether its thread had actually exited. A thread that didn't finish in time was dropped from tracking anyway, so active_connections under-reported live connections, and a later stop()/drop_connections() could never retry shutting it down -- permanently leaking that thread and its fds. Fixed by only popping entries whose thread is confirmed dead (`not thread.is_alive()`) after the join; still-alive entries stay tracked until _forward_loop's own self-removal (already lock-protected and idempotent) reaps them, so a subsequent shutdown call can retry. - Added tests/unit/test_tcp_proxy.py, a checked-in deterministic regression test (TcpProxy has no CCM/cluster dependency, only sockets, so it runs as a plain fast unit test against a local dummy TCP echo backend). It covers: (a) the exact regression above -- neutering _shutdown_pair and shrinking one thread's join wait to deterministically force the "still alive after the timeout" path, and asserting the connection stays tracked until a retried drop_connections() actually reaps it -- and (b) a concurrent stress test that hammers drop_connections() from multiple threads while other threads continuously open/close real connections, asserting no unhandled exceptions and no forwarder threads left alive once stop() returns. Validation: - Standalone stress harness (no CCM needed) driving concurrent clients through TcpProxy while repeatedly calling drop_connections()/stop()+restart from another thread: pre-fix, 40 iterations produced 162 unhandled ValueError crashes; post-fix, 40 iterations (same parameters) and a follow-up 150-iteration run produced zero corruptions/exceptions/leaked threads. - Standalone stress harness driving concurrent readers directly exercising _live_addresses() against concurrent add_node()/ remove_node() churn: pre-fix, 313 "dictionary changed size during iteration" RuntimeErrors over 447k calls in 5s; post-fix, zero errors over 1.9M+ calls across three separate runs. - Full end-to-end runs of TestFullNodeReplacementThroughNlb::test_should_survive_full_node_replacement_through_nlb against a real CCM cluster, both before and after the fix, to check for behavioral regressions and reproduce the reported flakiness. - New tests/unit/test_tcp_proxy.py: 30/30 clean runs with the active_connections fix in place; with the fix reverted, the targeted regression test failed deterministically 15/15 runs (active_connections incorrectly reported 0 instead of 1 for a still-alive forwarder thread), confirming the test actually catches the bug it targets. - Full tests/unit/ suite: 722 passed, 88 skipped, 0 failed. Fixes #948. Co-Authored-By: Claude Sonnet 5 --- .../standard/test_client_routes.py | 113 ++++++-- tests/unit/test_tcp_proxy.py | 259 ++++++++++++++++++ 2 files changed, 351 insertions(+), 21 deletions(-) create mode 100644 tests/unit/test_tcp_proxy.py diff --git a/tests/integration/standard/test_client_routes.py b/tests/integration/standard/test_client_routes.py index 292eabca30..8e45cf7d93 100644 --- a/tests/integration/standard/test_client_routes.py +++ b/tests/integration/standard/test_client_routes.py @@ -73,7 +73,7 @@ def __init__(self, listen_host, listen_port, target_host, target_port): self._running = False self._thread = None self._lock = threading.Lock() - self._connections = set() + self._connections = {} # (client_sock, target_sock) -> forwarder thread self.total_connections = 0 def start(self): @@ -92,16 +92,12 @@ def start(self): self.target_host, self.target_port) def stop(self): - self._running = False if self._server_sock: try: self._server_sock.close() except Exception: pass - with self._lock: - for csock, tsock in list(self._connections): - self._close_pair(csock, tsock) - self._connections.clear() + self._shutdown_and_join_connections(stopping=True) if self._thread: self._thread.join(timeout=5) log.info("TcpProxy stopped %s:%d", self.listen_host, self.listen_port) @@ -120,12 +116,45 @@ def retarget(self, new_host, new_port): def drop_connections(self): """Forcibly close all active connections.""" - with self._lock: - for csock, tsock in list(self._connections): - self._close_pair(csock, tsock) - self._connections.clear() + self._shutdown_and_join_connections() log.info("TcpProxy %s:%d dropped all connections", self.listen_host, self.listen_port) + def _shutdown_and_join_connections(self, stopping=False): + """ + Shut down (not close) each connection's sockets to unblock its + forwarder thread, then join it. Only the forwarder thread itself + closes its sockets, avoiding a close-vs-still-in-use fd-reuse race. + + stopping=True (stop() only) flips _running to False under the same + lock as the connections snapshot, so no connection registered by + _handle_new_connection can be missed. + """ + with self._lock: + if stopping: + self._running = False + connections = list(self._connections.items()) + for (csock, tsock), _thread in connections: + self._shutdown_pair(csock, tsock) + finished_keys = [] + for (csock, tsock), thread in connections: + thread.join(timeout=5) + if thread.is_alive(): + # Do NOT drop this entry from self._connections: it is + # still a live thread owning open fds. Leaving it tracked + # lets active_connections reflect reality and lets a + # subsequent stop()/drop_connections() retry the shutdown + # and join. _forward_loop() removes its own entry (under + # _lock) once it actually exits, so there's no leak here. + log.warning( + "TcpProxy %s:%d: forwarder thread %s did not exit " + "within timeout; leaked fds are possible", + self.listen_host, self.listen_port, thread.name) + else: + finished_keys.append((csock, tsock)) + with self._lock: + for key in finished_keys: + self._connections.pop(key, None) + def _run(self): while self._running: try: @@ -153,14 +182,32 @@ def _handle_new_connection(self, client_sock, target_host=None, target_port=None client_sock.close() return - with self._lock: - self._connections.add((client_sock, target_sock)) - self.total_connections += 1 - t = threading.Thread(target=self._forward_loop, args=(client_sock, target_sock), daemon=True) - t.start() + # Register then start() atomically under _lock, in that order: + # otherwise a short-lived thread could finish (and clean up) + # before being registered, leaking the entry, or run unseen by + # a concurrent stop()/drop_connections(). Also re-check + # _running, to reject connections after shutdown has begun. + with self._lock: + if not self._running: + target_sock.close() + client_sock.close() + return + self._connections[(client_sock, target_sock)] = t + self.total_connections += 1 + try: + t.start() + except Exception as e: + # Undo registration: join()-ing an unstarted thread later + # would raise RuntimeError. + self._connections.pop((client_sock, target_sock), None) + self.total_connections -= 1 + log.warning("TcpProxy %s:%d failed to start forwarder thread: %s", + self.listen_host, self.listen_port, e) + client_sock.close() + target_sock.close() def _forward_loop(self, client_sock, target_sock): try: @@ -178,7 +225,7 @@ def _forward_loop(self, client_sock, target_sock): pass finally: with self._lock: - self._connections.discard((client_sock, target_sock)) + self._connections.pop((client_sock, target_sock), None) self._close_pair(client_sock, target_sock) @staticmethod @@ -189,6 +236,15 @@ def _close_pair(csock, tsock): except Exception: pass + @staticmethod + def _shutdown_pair(csock, tsock): + """Best-effort shutdown (not close) to interrupt a thread blocked in select()/recv().""" + for s in (csock, tsock): + try: + s.shutdown(socket.SHUT_RDWR) + except OSError: + pass + class NLBEmulator: """ @@ -227,7 +283,8 @@ def __init__(self, discovery_port=0, self._node_proxies = {} self._discovery_proxy = None self._rr_index = 0 - self._lock = threading.Lock() + # RLock: add_node() holds it while _add_node_proxy() re-acquires it. + self._lock = threading.RLock() self._running = False def start(self, node_addresses): @@ -288,13 +345,17 @@ def stop(self): log.info("NLB stopped") def add_node(self, node_id, addr): - self._add_node_proxy(node_id, addr) + # Serialize against remove_node(): TcpProxy.stop() blocks until + # joined, so by the time we get the lock any freed fds are reaped. + with self._lock: + self._add_node_proxy(node_id, addr) def remove_node(self, node_id): with self._lock: proxy = self._node_proxies.pop(node_id, None) + if proxy: + proxy.stop() if proxy: - proxy.stop() log.info("NLB removed node %d", node_id) def node_port(self, node_id): @@ -328,8 +389,18 @@ def _add_node_proxy(self, node_id, addr): node_id, self.LISTEN_HOST, port, addr, self.native_port) def _live_addresses(self): - """IPs of nodes with active proxies.""" - return [p.target_host for p in self._node_proxies.values()] + """ + IPs of nodes with active proxies. + + Snapshots under _lock: rr_handler() (the discovery port's accept + handler) calls this from the discovery TcpProxy's own accept-loop + thread, concurrently with add_node()/remove_node() mutating + _node_proxies from other threads. Without the lock, a node being + added/removed mid-iteration can raise "RuntimeError: dictionary + changed size during iteration". + """ + with self._lock: + return [p.target_host for p in self._node_proxies.values()] def post_client_routes(contact_point, routes): """ diff --git a/tests/unit/test_tcp_proxy.py b/tests/unit/test_tcp_proxy.py new file mode 100644 index 0000000000..71d47d6150 --- /dev/null +++ b/tests/unit/test_tcp_proxy.py @@ -0,0 +1,259 @@ +# Copyright 2026 ScyllaDB, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Regression tests for the ``TcpProxy`` test helper's connection +shutdown/join synchronization path (GitHub issue #948). + +``TcpProxy`` is defined in +``tests/integration/standard/test_client_routes.py`` because it backs the +Client Routes / NLB integration tests, but it is a plain socket-based +helper with no dependency on a running Cassandra/Scylla cluster or CCM. +These tests exercise it directly against a local dummy TCP echo backend, +so they run as fast, deterministic, checked-in unit tests instead of only +being covered incidentally (and non-deterministically) by the integration +suite. + +Importing that module pulls in ``tests.integration``, whose module-level +code parses ``CASSANDRA_VERSION``/``SCYLLA_VERSION`` into a +``packaging.version.Version`` and raises if neither is set. That parsing +is the only thing gating the import -- no CCM/cluster is started merely by +importing the module -- so a harmless default is provided below when +running standalone (e.g. ``pytest tests/unit``), without overriding a real +value if one is already set (e.g. under the integration test runner). +""" + +import os +import socket +import threading +import time +import unittest +from unittest.mock import patch + +os.environ.setdefault("CASSANDRA_VERSION", "4.0.0") + +from tests.integration.standard.test_client_routes import TcpProxy # noqa: E402 + + +class _EchoServer: + """Minimal threaded TCP echo server used as TcpProxy's backend target.""" + + def __init__(self): + self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self._sock.bind(("127.0.0.1", 0)) + self.port = self._sock.getsockname()[1] + self._sock.listen(128) + self._sock.settimeout(0.2) + self._running = True + self._accept_thread = threading.Thread(target=self._accept_loop, daemon=True) + self._accept_thread.start() + + def _accept_loop(self): + while self._running: + try: + conn, _ = self._sock.accept() + except socket.timeout: + continue + except OSError: + return + threading.Thread(target=self._echo, args=(conn,), daemon=True).start() + + @staticmethod + def _echo(conn): + try: + while True: + data = conn.recv(4096) + if not data: + return + conn.sendall(data) + except OSError: + pass + finally: + try: + conn.close() + except OSError: + pass + + def stop(self): + self._running = False + try: + self._sock.close() + except OSError: + pass + self._accept_thread.join(timeout=2) + + +def _open_client(host, port, timeout=5): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + s.connect((host, port)) + return s + + +class TestTcpProxyShutdownJoin(unittest.TestCase): + """ + Regression coverage for the forwarder-thread bookkeeping bug described + in issue #948: ``_shutdown_and_join_connections`` used to unconditionally + discard every tracked connection from ``_connections``, even ones whose + forwarder thread was still alive after ``thread.join(timeout=5)`` timed + out. That made ``active_connections`` under-report live connections and + made it impossible for a later ``stop()``/``drop_connections()`` call to + retry reaping an orphaned thread, permanently leaking the thread and its + file descriptors. + """ + + def setUp(self): + self.echo = _EchoServer() + self.addCleanup(self.echo.stop) + self.proxy = TcpProxy("127.0.0.1", 0, "127.0.0.1", self.echo.port) + self.proxy.start() + self.addCleanup(self._safe_stop_proxy) + + def _safe_stop_proxy(self): + try: + self.proxy.stop() + except Exception: + pass + + def test_timed_out_forwarder_thread_is_retained_until_it_exits(self): + """ + Exact regression test for the fix: if a forwarder thread does not + exit within the join timeout, its entry must NOT be dropped from + ``_connections`` -- it must stay tracked (so ``active_connections`` + reflects reality and a later shutdown call can retry) until the + thread actually finishes. + """ + client = _open_client(self.proxy.listen_host, self.proxy.listen_port) + self.addCleanup(client.close) + client.sendall(b"ping") + self.assertEqual(client.recv(16), b"ping") + + self.assertEqual(self.proxy.active_connections, 1) + (csock, tsock), thread = list(self.proxy._connections.items())[0] + + # Shrink this thread's effective join timeout so the test doesn't + # have to block for the real 5s timeout, while neutering + # _shutdown_pair so the forwarder genuinely cannot be unblocked -- + # deterministically reproducing "still alive after the timeout". + real_join = thread.join + thread.join = lambda timeout=None: real_join(timeout=0.05) + try: + with patch.object(TcpProxy, "_shutdown_pair", + new=staticmethod(lambda a, b: None)): + self.proxy.drop_connections() + finally: + thread.join = real_join + + # The forwarder thread is still alive: the fixed code must keep + # tracking it instead of discarding the entry. + self.assertTrue(thread.is_alive(), + "test setup issue: forwarder thread should still " + "be running at this point") + self.assertEqual( + self.proxy.active_connections, 1, + "a still-alive forwarder thread's connection entry must not be " + "dropped after its join times out") + self.assertIn((csock, tsock), self.proxy._connections) + + # Retry for real: this time _shutdown_pair actually runs and + # unblocks the thread, so the retry can finish reaping it. + self.proxy.drop_connections() + + thread.join(timeout=5) + self.assertFalse(thread.is_alive()) + self.assertEqual(self.proxy.active_connections, 0) + self.assertNotIn((csock, tsock), self.proxy._connections) + + def test_concurrent_stop_and_drop_leaves_no_live_forwarders(self): + """ + Deterministic stress regression test: concurrently open/close real + connections through the proxy while other threads hammer + drop_connections(), then stop(); assert that (a) no unhandled + exception escaped any thread and (b) no forwarder thread is left + alive or tracked once stop() returns. + """ + errors = [] + stop_event = threading.Event() + forwarder_threads = set() + threads_lock = threading.Lock() + + def client_worker(): + while not stop_event.is_set(): + try: + s = _open_client(self.proxy.listen_host, + self.proxy.listen_port, timeout=1) + except OSError: + continue + try: + with self.proxy._lock: + with threads_lock: + forwarder_threads.update(self.proxy._connections.values()) + s.sendall(b"x") + s.recv(16) + except OSError: + pass + finally: + try: + s.close() + except OSError: + pass + time.sleep(0.005) + + def dropper_worker(): + while not stop_event.is_set(): + try: + self.proxy.drop_connections() + except Exception as e: + errors.append(e) + time.sleep(0.01) + + def thread_excepthook(args): + errors.append(args.exc_value) + + old_hook = threading.excepthook + threading.excepthook = thread_excepthook + try: + client_threads = [threading.Thread(target=client_worker) + for _ in range(4)] + dropper_threads = [threading.Thread(target=dropper_worker) + for _ in range(2)] + for t in client_threads + dropper_threads: + t.start() + + time.sleep(1.0) + + stop_event.set() + for t in client_threads + dropper_threads: + t.join(timeout=5) + self.assertFalse(t.is_alive()) + + self.proxy.stop() + finally: + threading.excepthook = old_hook + + self.assertEqual(errors, [], + "unhandled exceptions during concurrent " + "stop/drop: %r" % (errors,)) + self.assertEqual(self.proxy.active_connections, 0) + + with threads_lock: + collected = list(forwarder_threads) + for t in collected: + self.assertFalse(t.is_alive(), + "%s left alive after stop()" % t.name) + + +if __name__ == "__main__": + unittest.main() From bbd4f05b645de1bf9d6f7cdcb359ec9f8b93b145 Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Fri, 31 Jul 2026 14:31:10 -0400 Subject: [PATCH 118/200] tests: avoid assertNoLogs on Python 3.9 --- tests/unit/test_response_future.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_response_future.py b/tests/unit/test_response_future.py index cf1194a91f..232ecf6585 100644 --- a/tests/unit/test_response_future.py +++ b/tests/unit/test_response_future.py @@ -16,7 +16,7 @@ from collections import deque from threading import RLock -from unittest.mock import Mock, MagicMock, ANY +from unittest.mock import Mock, MagicMock, ANY, patch from cassandra import ConsistencyLevel, Unavailable, SchemaTargetType, SchemaChangeType, OperationTimedOut from cassandra.cluster import Session, ResponseFuture, NoHostAvailable, ProtocolVersion, ControlConnectionQueryFallback @@ -1316,8 +1316,9 @@ def test_set_result_no_metadata_statement_adopts_metadata_changed(self): ) # Ordinary METADATA_CHANGED handling, so no anomaly warning either. - with self.assertNoLogs('cassandra.cluster', level='WARNING'): + with patch('cassandra.cluster.log.warning') as warning: rf._set_result(None, None, None, response) + warning.assert_not_called() assert ps.result_metadata_and_id == (new_meta, b'new_id') @@ -1351,8 +1352,9 @@ def test_set_result_anomalous_metadata_id_warns_once_and_rearms(self): assert sum('result_metadata_id' in msg for msg in first.output) == 1 # Second identical anomalous response: no new warning (deduped). - with self.assertNoLogs('cassandra.cluster', level='WARNING'): + with patch('cassandra.cluster.log.warning') as warning: rf._set_result(None, None, None, anomalous) + warning.assert_not_called() assert ps.result_metadata_and_id == (old_meta, b'old_id') # A genuine METADATA_CHANGED recovers the metadata and re-arms the warning. From a4267874f9e07d4bf5d20db28ebfef5d2a330b53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20M=C4=99drek?= Date: Tue, 4 Aug 2026 11:36:33 +0200 Subject: [PATCH 119/200] test: move TcpProxy to tests/tcp_proxy.py to fix collection tests/unit/test_tcp_proxy.py, added in d99dc460, imported its subject (TcpProxy) from tests/integration/standard/test_client_routes.py, which transitively imports tests/integration/__init__.py. That module guards its ccmlib imports with try/except ImportError, but then unconditionally declares `class Cassandra41CCMCluster(CCMCluster)` at module level, so on any environment without ccmlib installed the import fails with: NameError: name 'CCMCluster' is not defined This broke test collection consistently on the windows-2022 job, where ccmlib is absent. The latent defect in tests/integration/__init__.py predates d99dc460; that commit merely became the first unit test to import tests.integration and thus the first to expose it. TcpProxy is a plain socket-based helper -- it depends only on socket, select and threading, and needs neither CCM nor a running Cassandra/Scylla cluster -- so it does not belong behind that import. Move it verbatim into a new tests/tcp_proxy.py and import it from both call sites: - tests/integration/standard/test_client_routes.py now imports TcpProxy from tests.tcp_proxy; its `select` and `socket` imports, used only by the moved class, are dropped. - tests/unit/test_tcp_proxy.py imports from tests.tcp_proxy and no longer needs its os.environ.setdefault("CASSANDRA_VERSION", ...) shim, which existed solely to get tests.integration's module-level version parsing to succeed. The shim and the docstring paragraph explaining it are removed. The class body is byte-identical to the original; only the new module's license header, docstring and imports are new. No driver code is touched and no test behavior changes. Validation: - pytest tests/unit/test_tcp_proxy.py: 2 passed with neither CASSANDRA_VERSION nor SCYLLA_VERSION set, i.e. the unit test no longer imports tests.integration at all. - tests/integration/standard/test_client_routes.py compiles clean with no imports left unused. Fixes: scylladb/python-driver#965 --- .../standard/test_client_routes.py | 195 +-------------- tests/tcp_proxy.py | 224 ++++++++++++++++++ tests/unit/test_tcp_proxy.py | 27 +-- 3 files changed, 232 insertions(+), 214 deletions(-) create mode 100644 tests/tcp_proxy.py diff --git a/tests/integration/standard/test_client_routes.py b/tests/integration/standard/test_client_routes.py index 8e45cf7d93..f365a628f8 100644 --- a/tests/integration/standard/test_client_routes.py +++ b/tests/integration/standard/test_client_routes.py @@ -24,9 +24,7 @@ import logging import os -import select import shutil -import socket import ssl import subprocess import tempfile @@ -50,202 +48,11 @@ wait_for_node_socket, skip_scylla_version_lt, ) +from tests.tcp_proxy import TcpProxy from tests.util import wait_until_not_raised log = logging.getLogger(__name__) -class TcpProxy: - """ - A simple TCP proxy that forwards connections from a local listen port - to a target (host, port). Tracks active connections so tests can - verify that traffic flows through the proxy. - """ - - BUF_SIZE = 65536 - - def __init__(self, listen_host, listen_port, target_host, target_port): - self.listen_host = listen_host - self.listen_port = listen_port - self.target_host = target_host - self.target_port = target_port - - self._server_sock = None - self._running = False - self._thread = None - self._lock = threading.Lock() - self._connections = {} # (client_sock, target_sock) -> forwarder thread - self.total_connections = 0 - - def start(self): - self._server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self._server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - self._server_sock.bind((self.listen_host, self.listen_port)) - self.listen_port = self._server_sock.getsockname()[1] - self._server_sock.listen(128) - self._server_sock.setblocking(False) - self._running = True - self._thread = threading.Thread(target=self._run, daemon=True, - name="proxy-%s:%d" % (self.listen_host, self.listen_port)) - self._thread.start() - log.info("TcpProxy started %s:%d -> %s:%d", - self.listen_host, self.listen_port, - self.target_host, self.target_port) - - def stop(self): - if self._server_sock: - try: - self._server_sock.close() - except Exception: - pass - self._shutdown_and_join_connections(stopping=True) - if self._thread: - self._thread.join(timeout=5) - log.info("TcpProxy stopped %s:%d", self.listen_host, self.listen_port) - - @property - def active_connections(self): - with self._lock: - return len(self._connections) - - def retarget(self, new_host, new_port): - """Change the backend target for new connections (existing ones keep the old target).""" - self.target_host = new_host - self.target_port = new_port - log.info("TcpProxy %s:%d retargeted to %s:%d", - self.listen_host, self.listen_port, new_host, new_port) - - def drop_connections(self): - """Forcibly close all active connections.""" - self._shutdown_and_join_connections() - log.info("TcpProxy %s:%d dropped all connections", self.listen_host, self.listen_port) - - def _shutdown_and_join_connections(self, stopping=False): - """ - Shut down (not close) each connection's sockets to unblock its - forwarder thread, then join it. Only the forwarder thread itself - closes its sockets, avoiding a close-vs-still-in-use fd-reuse race. - - stopping=True (stop() only) flips _running to False under the same - lock as the connections snapshot, so no connection registered by - _handle_new_connection can be missed. - """ - with self._lock: - if stopping: - self._running = False - connections = list(self._connections.items()) - for (csock, tsock), _thread in connections: - self._shutdown_pair(csock, tsock) - finished_keys = [] - for (csock, tsock), thread in connections: - thread.join(timeout=5) - if thread.is_alive(): - # Do NOT drop this entry from self._connections: it is - # still a live thread owning open fds. Leaving it tracked - # lets active_connections reflect reality and lets a - # subsequent stop()/drop_connections() retry the shutdown - # and join. _forward_loop() removes its own entry (under - # _lock) once it actually exits, so there's no leak here. - log.warning( - "TcpProxy %s:%d: forwarder thread %s did not exit " - "within timeout; leaked fds are possible", - self.listen_host, self.listen_port, thread.name) - else: - finished_keys.append((csock, tsock)) - with self._lock: - for key in finished_keys: - self._connections.pop(key, None) - - def _run(self): - while self._running: - try: - readable, _, _ = select.select([self._server_sock], [], [], 0.2) - except (ValueError, OSError): - break - for sock in readable: - if sock is self._server_sock: - try: - client_sock, _ = self._server_sock.accept() - except OSError: - continue - self._handle_new_connection(client_sock) - - def _handle_new_connection(self, client_sock, target_host=None, target_port=None): - target_host = target_host or self.target_host - target_port = target_port or self.target_port - try: - target_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - target_sock.connect((target_host, target_port)) - except Exception as e: - log.warning("TcpProxy %s:%d failed to connect to target %s:%d: %s", - self.listen_host, self.listen_port, - target_host, target_port, e) - client_sock.close() - return - - t = threading.Thread(target=self._forward_loop, - args=(client_sock, target_sock), - daemon=True) - # Register then start() atomically under _lock, in that order: - # otherwise a short-lived thread could finish (and clean up) - # before being registered, leaking the entry, or run unseen by - # a concurrent stop()/drop_connections(). Also re-check - # _running, to reject connections after shutdown has begun. - with self._lock: - if not self._running: - target_sock.close() - client_sock.close() - return - self._connections[(client_sock, target_sock)] = t - self.total_connections += 1 - try: - t.start() - except Exception as e: - # Undo registration: join()-ing an unstarted thread later - # would raise RuntimeError. - self._connections.pop((client_sock, target_sock), None) - self.total_connections -= 1 - log.warning("TcpProxy %s:%d failed to start forwarder thread: %s", - self.listen_host, self.listen_port, e) - client_sock.close() - target_sock.close() - - def _forward_loop(self, client_sock, target_sock): - try: - while self._running: - readable, _, _ = select.select([client_sock, target_sock], [], [], 0.5) - for sock in readable: - data = sock.recv(self.BUF_SIZE) - if not data: - return - if sock is client_sock: - target_sock.sendall(data) - else: - client_sock.sendall(data) - except (OSError, ConnectionResetError, BrokenPipeError): - pass - finally: - with self._lock: - self._connections.pop((client_sock, target_sock), None) - self._close_pair(client_sock, target_sock) - - @staticmethod - def _close_pair(csock, tsock): - for s in (csock, tsock): - try: - s.close() - except Exception: - pass - - @staticmethod - def _shutdown_pair(csock, tsock): - """Best-effort shutdown (not close) to interrupt a thread blocked in select()/recv().""" - for s in (csock, tsock): - try: - s.shutdown(socket.SHUT_RDWR) - except OSError: - pass - - class NLBEmulator: """ Emulates a Network Load Balancer for a CCM cluster. diff --git a/tests/tcp_proxy.py b/tests/tcp_proxy.py new file mode 100644 index 0000000000..e87df3b430 --- /dev/null +++ b/tests/tcp_proxy.py @@ -0,0 +1,224 @@ +# Copyright 2026 ScyllaDB, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Socket-level ``TcpProxy`` test helper. + +It backs the Client Routes / NLB integration tests +(``tests/integration/standard/test_client_routes.py``), but has no +dependency on CCM or a running Cassandra/Scylla cluster, so it lives here +rather than in ``tests.integration`` -- that lets the unit test suite +(``tests/unit/test_tcp_proxy.py``) exercise it without importing +``tests.integration``, whose module-level code requires ``ccmlib`` and +CASSANDRA_VERSION/SCYLLA_VERSION to be set. +""" + +import logging +import select +import socket +import threading + +log = logging.getLogger(__name__) + + +class TcpProxy: + """ + A simple TCP proxy that forwards connections from a local listen port + to a target (host, port). Tracks active connections so tests can + verify that traffic flows through the proxy. + """ + + BUF_SIZE = 65536 + + def __init__(self, listen_host, listen_port, target_host, target_port): + self.listen_host = listen_host + self.listen_port = listen_port + self.target_host = target_host + self.target_port = target_port + + self._server_sock = None + self._running = False + self._thread = None + self._lock = threading.Lock() + self._connections = {} # (client_sock, target_sock) -> forwarder thread + self.total_connections = 0 + + def start(self): + self._server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self._server_sock.bind((self.listen_host, self.listen_port)) + self.listen_port = self._server_sock.getsockname()[1] + self._server_sock.listen(128) + self._server_sock.setblocking(False) + self._running = True + self._thread = threading.Thread(target=self._run, daemon=True, + name="proxy-%s:%d" % (self.listen_host, self.listen_port)) + self._thread.start() + log.info("TcpProxy started %s:%d -> %s:%d", + self.listen_host, self.listen_port, + self.target_host, self.target_port) + + def stop(self): + if self._server_sock: + try: + self._server_sock.close() + except Exception: + pass + self._shutdown_and_join_connections(stopping=True) + if self._thread: + self._thread.join(timeout=5) + log.info("TcpProxy stopped %s:%d", self.listen_host, self.listen_port) + + @property + def active_connections(self): + with self._lock: + return len(self._connections) + + def retarget(self, new_host, new_port): + """Change the backend target for new connections (existing ones keep the old target).""" + self.target_host = new_host + self.target_port = new_port + log.info("TcpProxy %s:%d retargeted to %s:%d", + self.listen_host, self.listen_port, new_host, new_port) + + def drop_connections(self): + """Forcibly close all active connections.""" + self._shutdown_and_join_connections() + log.info("TcpProxy %s:%d dropped all connections", self.listen_host, self.listen_port) + + def _shutdown_and_join_connections(self, stopping=False): + """ + Shut down (not close) each connection's sockets to unblock its + forwarder thread, then join it. Only the forwarder thread itself + closes its sockets, avoiding a close-vs-still-in-use fd-reuse race. + + stopping=True (stop() only) flips _running to False under the same + lock as the connections snapshot, so no connection registered by + _handle_new_connection can be missed. + """ + with self._lock: + if stopping: + self._running = False + connections = list(self._connections.items()) + for (csock, tsock), _thread in connections: + self._shutdown_pair(csock, tsock) + finished_keys = [] + for (csock, tsock), thread in connections: + thread.join(timeout=5) + if thread.is_alive(): + # Do NOT drop this entry from self._connections: it is + # still a live thread owning open fds. Leaving it tracked + # lets active_connections reflect reality and lets a + # subsequent stop()/drop_connections() retry the shutdown + # and join. _forward_loop() removes its own entry (under + # _lock) once it actually exits, so there's no leak here. + log.warning( + "TcpProxy %s:%d: forwarder thread %s did not exit " + "within timeout; leaked fds are possible", + self.listen_host, self.listen_port, thread.name) + else: + finished_keys.append((csock, tsock)) + with self._lock: + for key in finished_keys: + self._connections.pop(key, None) + + def _run(self): + while self._running: + try: + readable, _, _ = select.select([self._server_sock], [], [], 0.2) + except (ValueError, OSError): + break + for sock in readable: + if sock is self._server_sock: + try: + client_sock, _ = self._server_sock.accept() + except OSError: + continue + self._handle_new_connection(client_sock) + + def _handle_new_connection(self, client_sock, target_host=None, target_port=None): + target_host = target_host or self.target_host + target_port = target_port or self.target_port + try: + target_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + target_sock.connect((target_host, target_port)) + except Exception as e: + log.warning("TcpProxy %s:%d failed to connect to target %s:%d: %s", + self.listen_host, self.listen_port, + target_host, target_port, e) + client_sock.close() + return + + t = threading.Thread(target=self._forward_loop, + args=(client_sock, target_sock), + daemon=True) + # Register then start() atomically under _lock, in that order: + # otherwise a short-lived thread could finish (and clean up) + # before being registered, leaking the entry, or run unseen by + # a concurrent stop()/drop_connections(). Also re-check + # _running, to reject connections after shutdown has begun. + with self._lock: + if not self._running: + target_sock.close() + client_sock.close() + return + self._connections[(client_sock, target_sock)] = t + self.total_connections += 1 + try: + t.start() + except Exception as e: + # Undo registration: join()-ing an unstarted thread later + # would raise RuntimeError. + self._connections.pop((client_sock, target_sock), None) + self.total_connections -= 1 + log.warning("TcpProxy %s:%d failed to start forwarder thread: %s", + self.listen_host, self.listen_port, e) + client_sock.close() + target_sock.close() + + def _forward_loop(self, client_sock, target_sock): + try: + while self._running: + readable, _, _ = select.select([client_sock, target_sock], [], [], 0.5) + for sock in readable: + data = sock.recv(self.BUF_SIZE) + if not data: + return + if sock is client_sock: + target_sock.sendall(data) + else: + client_sock.sendall(data) + except (OSError, ConnectionResetError, BrokenPipeError): + pass + finally: + with self._lock: + self._connections.pop((client_sock, target_sock), None) + self._close_pair(client_sock, target_sock) + + @staticmethod + def _close_pair(csock, tsock): + for s in (csock, tsock): + try: + s.close() + except Exception: + pass + + @staticmethod + def _shutdown_pair(csock, tsock): + """Best-effort shutdown (not close) to interrupt a thread blocked in select()/recv().""" + for s in (csock, tsock): + try: + s.shutdown(socket.SHUT_RDWR) + except OSError: + pass diff --git a/tests/unit/test_tcp_proxy.py b/tests/unit/test_tcp_proxy.py index 71d47d6150..4c173c6576 100644 --- a/tests/unit/test_tcp_proxy.py +++ b/tests/unit/test_tcp_proxy.py @@ -16,34 +16,21 @@ Regression tests for the ``TcpProxy`` test helper's connection shutdown/join synchronization path (GitHub issue #948). -``TcpProxy`` is defined in -``tests/integration/standard/test_client_routes.py`` because it backs the -Client Routes / NLB integration tests, but it is a plain socket-based -helper with no dependency on a running Cassandra/Scylla cluster or CCM. -These tests exercise it directly against a local dummy TCP echo backend, -so they run as fast, deterministic, checked-in unit tests instead of only -being covered incidentally (and non-deterministically) by the integration -suite. - -Importing that module pulls in ``tests.integration``, whose module-level -code parses ``CASSANDRA_VERSION``/``SCYLLA_VERSION`` into a -``packaging.version.Version`` and raises if neither is set. That parsing -is the only thing gating the import -- no CCM/cluster is started merely by -importing the module -- so a harmless default is provided below when -running standalone (e.g. ``pytest tests/unit``), without overriding a real -value if one is already set (e.g. under the integration test runner). +``TcpProxy`` lives in ``tests/tcp_proxy.py`` because it backs the Client +Routes / NLB integration tests, but it is a plain socket-based helper with +no dependency on a running Cassandra/Scylla cluster or CCM. These tests +exercise it directly against a local dummy TCP echo backend, so they run as +fast, deterministic, checked-in unit tests instead of only being covered +incidentally (and non-deterministically) by the integration suite. """ -import os import socket import threading import time import unittest from unittest.mock import patch -os.environ.setdefault("CASSANDRA_VERSION", "4.0.0") - -from tests.integration.standard.test_client_routes import TcpProxy # noqa: E402 +from tests.tcp_proxy import TcpProxy class _EchoServer: From fabb4d04fe6ec960b9dd32c9ab857967ea93c9a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20M=C4=99drek?= Date: Mon, 3 Aug 2026 23:00:46 +0200 Subject: [PATCH 120/200] Fix regular expression strings The strings didn't use the characters they intended because the backslashes effectively resulted in special characters. We fix them by marking the strings as raw. --- tests/integration/__init__.py | 6 +++--- tests/unit/test_exception.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py index a91617f494..6118d961da 100644 --- a/tests/integration/__init__.py +++ b/tests/integration/__init__.py @@ -976,9 +976,9 @@ def __new__(cls, **kwargs): # introduced by CASSANDRA-15234 class Cassandra41CCMCluster(CCMCluster): __test__ = False - IN_MS_REGEX = re.compile('^(\w+)_in_ms$') - IN_KB_REGEX = re.compile('^(\w+)_in_kb$') - ENABLE_REGEX = re.compile('^enable_(\w+)$') + IN_MS_REGEX = re.compile(r'^(\w+)_in_ms$') + IN_KB_REGEX = re.compile(r'^(\w+)_in_kb$') + ENABLE_REGEX = re.compile(r'^enable_(\w+)$') def _get_config_key(self, k, v): if "." in k: diff --git a/tests/unit/test_exception.py b/tests/unit/test_exception.py index 6bddd96a4b..0ac4052a63 100644 --- a/tests/unit/test_exception.py +++ b/tests/unit/test_exception.py @@ -29,7 +29,7 @@ def extract_consistency(self, msg): :param msg: message with consistency value :return: String representing consistency value """ - match = re.search("'consistency':\s+'([\w\s]+)'", msg) + match = re.search(r"'consistency':\s+'([\w\s]+)'", msg) return match and match.group(1) def test_timeout_consistency(self): From 1d7e60110b852d61a24988ffc168292bb82afdf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20M=C4=99drek?= Date: Mon, 3 Aug 2026 23:02:54 +0200 Subject: [PATCH 121/200] Avoid using is not with a literal The operator `is not` comapres the memory addresses of two objects. Since we're comparing an expression against a literal, it made no sense and was reported by Python. Fix it by moving on to using the operator `!=`. --- tests/unit/io/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/io/utils.py b/tests/unit/io/utils.py index f43224058c..b821ee1897 100644 --- a/tests/unit/io/utils.py +++ b/tests/unit/io/utils.py @@ -120,7 +120,7 @@ def submit_and_wait_for_completion(unit_test, create_timer, start, end, incremen pending_callbacks.append(callback) # wait for all the callbacks associated with the timers to be invoked - while len(pending_callbacks) is not 0: + while len(pending_callbacks) != 0: for callback in pending_callbacks: if callback.was_invoked(): pending_callbacks.remove(callback) From e9773cde878aeb54df0701cff013c7e78f9ace63 Mon Sep 17 00:00:00 2001 From: Brad Schoening Date: Tue, 16 Dec 2025 13:22:39 -0500 Subject: [PATCH 122/200] remove obsolete __future__ import absolute_import patch by Brad Schoening; reviewed by Brad Schoening and Bret McGuire reference: https://github.com/apache/cassandra-python-driver/pull/1263 --- cassandra/cluster.py | 1 - cassandra/connection.py | 1 - cassandra/cqltypes.py | 1 - cassandra/protocol.py | 1 - tests/integration/cqlengine/query/test_queryset.py | 1 - 5 files changed, 5 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 88c8d2707a..751f5e34ff 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -16,7 +16,6 @@ This module houses the main classes you will interact with, :class:`.Cluster` and :class:`.Session`. """ -from __future__ import absolute_import import atexit import datetime diff --git a/cassandra/connection.py b/cassandra/connection.py index fd7808afc5..ac2578a16c 100644 --- a/cassandra/connection.py +++ b/cassandra/connection.py @@ -12,7 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import absolute_import # to enable import io from stdlib from collections import defaultdict, deque import errno from functools import wraps, partial, total_ordering diff --git a/cassandra/cqltypes.py b/cassandra/cqltypes.py index 99018eef03..4d63ae5195 100644 --- a/cassandra/cqltypes.py +++ b/cassandra/cqltypes.py @@ -27,7 +27,6 @@ # for example), these classes would be a good place to tack on # .from_cql_literal() and .as_cql_literal() classmethods (or whatever). -from __future__ import absolute_import # to enable import io from stdlib import ast from binascii import unhexlify import calendar diff --git a/cassandra/protocol.py b/cassandra/protocol.py index 9dfdbf3022..4aa52ee697 100644 --- a/cassandra/protocol.py +++ b/cassandra/protocol.py @@ -12,7 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import absolute_import # to enable import io from stdlib from collections import namedtuple import logging import socket diff --git a/tests/integration/cqlengine/query/test_queryset.py b/tests/integration/cqlengine/query/test_queryset.py index 34b4ab5964..a4420e8283 100644 --- a/tests/integration/cqlengine/query/test_queryset.py +++ b/tests/integration/cqlengine/query/test_queryset.py @@ -11,7 +11,6 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import absolute_import import unittest From 0e7b1a8a17fd316b4a83323887bbaec720ddaa84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20M=C4=99drek?= Date: Wed, 8 Jul 2026 14:02:42 +0200 Subject: [PATCH 123/200] Negotiate the TABLETS_ROUTING_V2 protocol extension Add per-connection negotiation of the TABLETS_ROUTING_V2 extension, the successor to TABLETS_ROUTING_V1. When the server advertises it in the SUPPORTED response, the driver echoes it back during STARTUP to opt in; a driver that negotiates v2 does not negotiate v1. While the feature is experimental the wire name carries the `_EXPERIMENTAL` suffix (TABLETS_ROUTING_V2_EXPERIMENTAL), and the server only advertises it when started with the `strongly-consistent-tables` experimental feature enabled. Also add the trailing tablet_version_block byte to the EXECUTE message body. The server reads exactly one such byte per EXECUTE on a connection that negotiated the extension, so the encoder writes one whenever the connection did -- coalescing an unset value to 0 -- and none otherwise. Later commits fill in the value from the cached tablet version. Deciding this from the connection's negotiated features rather than from the message is what lets one ExecuteMessage be sent, unmodified, on connections that negotiated differently. --- cassandra/protocol.py | 9 +++++- cassandra/protocol_features.py | 25 +++++++++++++--- tests/unit/test_protocol_features.py | 44 +++++++++++++++++++++++++++- 3 files changed, 72 insertions(+), 6 deletions(-) diff --git a/cassandra/protocol.py b/cassandra/protocol.py index 4aa52ee697..4a2444da88 100644 --- a/cassandra/protocol.py +++ b/cassandra/protocol.py @@ -653,9 +653,11 @@ class ExecuteMessage(_QueryMessage): def __init__(self, query_id, query_params, consistency_level, serial_consistency_level=None, fetch_size=None, paging_state=None, timestamp=None, skip_meta=False, - continuous_paging_options=None, result_metadata_id=None): + continuous_paging_options=None, result_metadata_id=None, + tablet_version_block=None): self.query_id = query_id self.result_metadata_id = result_metadata_id + self.tablet_version_block = tablet_version_block super(ExecuteMessage, self).__init__(query_params, consistency_level, serial_consistency_level, fetch_size, paging_state, timestamp, skip_meta, continuous_paging_options) @@ -688,6 +690,11 @@ def send_body(self, f, protocol_version, protocol_features=None): # responds with full metadata plus the current id. write_string(f, self.result_metadata_id if self.result_metadata_id is not None else b'') self._write_query_params(f, protocol_version, protocol_features) + if protocol_features is not None and protocol_features.tablets_routing_v2: + # A V2 connection makes the server read exactly one trailing byte per + # EXECUTE, so always write one. Coalesce a missing value to 0 to keep + # the frame in sync. + write_byte(f, self.tablet_version_block if self.tablet_version_block is not None else 0) CUSTOM_TYPE = object() diff --git a/cassandra/protocol_features.py b/cassandra/protocol_features.py index 7165117e80..c2bc7ca417 100644 --- a/cassandra/protocol_features.py +++ b/cassandra/protocol_features.py @@ -11,23 +11,31 @@ RATE_LIMIT_ERROR_EXTENSION = "SCYLLA_RATE_LIMIT_ERROR" TABLETS_ROUTING_V1 = "TABLETS_ROUTING_V1" USE_METADATA_ID = "SCYLLA_USE_METADATA_ID" +# The server advertises and expects this exact extension name in SUPPORTED/STARTUP +# (see scylladb transport/cql_protocol_extension.cc). While the feature is gated +# behind the server's `strongly-consistent-tables` experimental flag, the wire +# name carries the `_EXPERIMENTAL` suffix. +TABLETS_ROUTING_V2 = "TABLETS_ROUTING_V2_EXPERIMENTAL" class ProtocolFeatures(object): rate_limit_error = None shard_id = 0 sharding_info = None tablets_routing_v1 = False + tablets_routing_v2 = False lwt_info = None use_metadata_id = False # Keyword-only so that independently developed protocol extensions can add # new fields without conflicting over positional-argument order. - def __init__(self, *, rate_limit_error=None, shard_id=0, sharding_info=None, tablets_routing_v1=False, lwt_info=None, + def __init__(self, *, rate_limit_error=None, shard_id=0, sharding_info=None, + tablets_routing_v1=False, tablets_routing_v2=False, lwt_info=None, use_metadata_id=False): self.rate_limit_error = rate_limit_error self.shard_id = shard_id self.sharding_info = sharding_info self.tablets_routing_v1 = tablets_routing_v1 + self.tablets_routing_v2 = tablets_routing_v2 self.lwt_info = lwt_info self.use_metadata_id = use_metadata_id @@ -36,11 +44,12 @@ def parse_from_supported(supported): rate_limit_error = ProtocolFeatures.maybe_parse_rate_limit_error(supported) shard_id, sharding_info = ProtocolFeatures.parse_sharding_info(supported) tablets_routing_v1 = ProtocolFeatures.parse_tablets_info(supported) + tablets_routing_v2 = ProtocolFeatures.parse_tablets_v2_info(supported) lwt_info = ProtocolFeatures.parse_lwt_info(supported) use_metadata_id = ProtocolFeatures.parse_use_metadata_id(supported) return ProtocolFeatures(rate_limit_error=rate_limit_error, shard_id=shard_id, sharding_info=sharding_info, - tablets_routing_v1=tablets_routing_v1, lwt_info=lwt_info, - use_metadata_id=use_metadata_id) + tablets_routing_v1=tablets_routing_v1, tablets_routing_v2=tablets_routing_v2, + lwt_info=lwt_info, use_metadata_id=use_metadata_id) @staticmethod def maybe_parse_rate_limit_error(supported): @@ -62,7 +71,11 @@ def get_cql_extension_field(vals, key): def add_startup_options(self, options): if self.rate_limit_error is not None: options[RATE_LIMIT_ERROR_EXTENSION] = "" - if self.tablets_routing_v1: + # Only one of TABLETS_ROUTING_V{1,2} should be negotiated + # per connection. Hence the if-else branch. + if self.tablets_routing_v2: + options[TABLETS_ROUTING_V2] = "" + elif self.tablets_routing_v1: options[TABLETS_ROUTING_V1] = "" if self.lwt_info is not None: options[LWT_ADD_METADATA_MARK] = str(self.lwt_info.lwt_meta_bit_mask) @@ -92,6 +105,10 @@ def parse_sharding_info(options): def parse_tablets_info(options): return TABLETS_ROUTING_V1 in options + @staticmethod + def parse_tablets_v2_info(options): + return TABLETS_ROUTING_V2 in options + @staticmethod def parse_use_metadata_id(options): """Return True if the ``SCYLLA_USE_METADATA_ID`` extension is advertised in ``options``.""" diff --git a/tests/unit/test_protocol_features.py b/tests/unit/test_protocol_features.py index 387583680b..915f8b84fd 100644 --- a/tests/unit/test_protocol_features.py +++ b/tests/unit/test_protocol_features.py @@ -2,7 +2,7 @@ import logging -from cassandra.protocol_features import ProtocolFeatures +from cassandra.protocol_features import ProtocolFeatures, TABLETS_ROUTING_V1, TABLETS_ROUTING_V2 LOGGER = logging.getLogger(__name__) @@ -57,3 +57,45 @@ def test_use_metadata_id_not_in_startup_when_not_negotiated(self): startup = {} protocol_features.add_startup_options(startup) assert 'SCYLLA_USE_METADATA_ID' not in startup + + def test_tablets_routing_v2_negotiation(self): + """V2 is detected from SUPPORTED and subsumes V1 in STARTUP options.""" + options = { + TABLETS_ROUTING_V1: [''], + TABLETS_ROUTING_V2: [''], + } + features = ProtocolFeatures.parse_from_supported(options) + assert features.tablets_routing_v1 is True + assert features.tablets_routing_v2 is True + + # V2 subsumes V1: only TABLETS_ROUTING_V2 should appear in startup. + startup = {} + features.add_startup_options(startup) + assert TABLETS_ROUTING_V2 in startup + assert TABLETS_ROUTING_V1 not in startup + + def test_tablets_routing_v1_only(self): + """When server only advertises V1, only V1 is negotiated.""" + options = { + TABLETS_ROUTING_V1: [''], + } + features = ProtocolFeatures.parse_from_supported(options) + assert features.tablets_routing_v1 is True + assert features.tablets_routing_v2 is False + + startup = {} + features.add_startup_options(startup) + assert TABLETS_ROUTING_V1 in startup + assert TABLETS_ROUTING_V2 not in startup + + def test_no_tablets_routing(self): + """When server advertises neither V1 nor V2.""" + options = {} + features = ProtocolFeatures.parse_from_supported(options) + assert features.tablets_routing_v1 is False + assert features.tablets_routing_v2 is False + + startup = {} + features.add_startup_options(startup) + assert TABLETS_ROUTING_V1 not in startup + assert TABLETS_ROUTING_V2 not in startup From 61e64affd0084a6e0894fdf967daffc261727558 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20M=C4=99drek?= Date: Wed, 8 Jul 2026 14:03:11 +0200 Subject: [PATCH 124/200] Track tablet_version and encode the tablet_version_block Store the server-provided 64-bit tablet_version on each cached Tablet and add helpers to encode it into the one-byte tablet_version_block exchanged on the wire. The version stays None until learned: on a cold start, and on a TABLETS_ROUTING_V1 connection, which never reports one. * Tablet.from_row normalizes the version to an unsigned 64-bit value. The server sends an unsigned hash, but the driver deserializes the payload field as a signed long, so the raw value can come back negative; masking to [0, 2**64) keeps the nibble extraction in choose_tablet_version_block consistent with the server's unsigned layout. * choose_tablet_version_block() packs a randomly chosen block index in the high nibble and that block's value in the low nibble, matching the server's locator::compare_tablet_version_block layout. Blocks are indexed from the least significant bits, so block i covers bits [i*4, i*4 + 4) of the version. A random index avoids any shared mutable counter on the hot path while still probing every nibble often enough to detect a server-side version change quickly. * random_tablet_version_block() returns a random byte for cold start, when no version is cached yet. --- cassandra/tablets.py | 46 +++++++++++++++++++++++--- tests/unit/test_tablets.py | 67 +++++++++++++++++++++++++++++++++++++- 2 files changed, 107 insertions(+), 6 deletions(-) diff --git a/cassandra/tablets.py b/cassandra/tablets.py index 96e61a50c2..216d802061 100644 --- a/cassandra/tablets.py +++ b/cassandra/tablets.py @@ -1,5 +1,6 @@ from bisect import bisect_left from operator import attrgetter +from random import getrandbits from threading import Lock from typing import Optional from uuid import UUID @@ -9,6 +10,32 @@ _get_last_token = attrgetter("last_token") +def choose_tablet_version_block(tablet_version: int) -> int: + """ + Encode a tablet_version_block byte from a cached tablet_version. + Picks a block index at random across calls. + Returns an int in [0, 255]. + + The byte layout: the high nibble is the block index, the low nibble is the value + of that block. Blocks are indexed from the least significant bits to the most + significant ones, so block `idx` occupies bits [idx*4, idx*4 + 4). + """ + # Pick the block index in [0, 15]; getrandbits(4) is a fast C call with no + # application-level shared state. + idx = getrandbits(4) + # Extract the 4-bit nibble at block index `idx` (0 = least significant). + shift = idx * 4 + nibble = (tablet_version >> shift) & 0xF + return (idx << 4) | nibble + + +def random_tablet_version_block() -> int: + """ + Generate a random tablet_version_block byte for cold start. + """ + return getrandbits(8) + + class Tablet(object): """ Represents a single ScyllaDB tablet. @@ -18,15 +45,19 @@ class Tablet(object): first_token = 0 last_token = 0 replicas = None + # uint64 hash; None means unknown -- a cold start, or a tablet learned over + # TABLETS_ROUTING_V1, which does not report a version. + tablet_version = None - def __init__(self, first_token=0, last_token=0, replicas=None): + def __init__(self, first_token=0, last_token=0, replicas=None, tablet_version=None): self.first_token = first_token self.last_token = last_token self.replicas = replicas + self.tablet_version = tablet_version def __str__(self): - return "" \ - % (self.first_token, self.last_token, self.replicas) + return "" \ + % (self.first_token, self.last_token, self.replicas, self.tablet_version) __repr__ = __str__ @staticmethod @@ -34,9 +65,14 @@ def _is_valid_tablet(replicas): return replicas is not None and len(replicas) != 0 @staticmethod - def from_row(first_token, last_token, replicas): + def from_row(first_token, last_token, replicas, tablet_version=None): if Tablet._is_valid_tablet(replicas): - tablet = Tablet(first_token, last_token, replicas) + if tablet_version is not None: + # tablet_version is an unsigned 64-bit value, but it is + # deserialized from the wire as a signed LongType; normalize it + # back to unsigned so it matches the server's representation. + tablet_version &= 0xFFFFFFFFFFFFFFFF + tablet = Tablet(first_token, last_token, replicas, tablet_version) return tablet return None diff --git a/tests/unit/test_tablets.py b/tests/unit/test_tablets.py index 7a40e7de4d..87478af46e 100644 --- a/tests/unit/test_tablets.py +++ b/tests/unit/test_tablets.py @@ -1,6 +1,6 @@ import unittest -from cassandra.tablets import Tablets, Tablet +from cassandra.tablets import Tablets, Tablet, choose_tablet_version_block, random_tablet_version_block class TabletsTest(unittest.TestCase): def compare_ranges(self, tablets, ranges): @@ -124,3 +124,68 @@ def __init__(self, v): # Token value 50 is not > first_token (100) of the tablet whose # last_token (200) is >= 50, so no match. self.assertIsNone(tablets.get_tablet_for_key("ks", "tb", Token(50))) + + +class TabletVersionBlockTest(unittest.TestCase): + """Tests for tablet_version_block encoding used by TABLETS_ROUTING_V2.""" + + def _server_block_matches(self, version, block): + """Reimplements the server's locator::compare_tablet_version_block.""" + block_value = block & 0x0F + block_index = (block & 0xF0) >> 4 + hash_block = (version >> (block_index * 4)) & 0x0F + return hash_block == block_value + + def test_choose_tablet_version_block_matches_server(self): + """Every block produced by the driver must match the server's check.""" + version = 0x0123456789ABCDEF + # The index is chosen randomly; sample enough times to exercise many indices. + for _ in range(256): + block = choose_tablet_version_block(version) + self.assertTrue(self._server_block_matches(version, block), + f"Block 0x{block:02X} did not match server check for version 0x{version:016X}") + + def test_choose_tablet_version_block_covers_all_indices(self): + """Over many calls the random index selection should probe every block + index, so that any server-side version change is eventually detected.""" + version = 0xFFFFFFFFFFFFFFFF # All nibbles are 0xF + seen_indices = set() + # 16 indices; 1000 draws makes a missing index astronomically unlikely. + for _ in range(1000): + block = choose_tablet_version_block(version) + seen_indices.add((block >> 4) & 0xF) + self.assertEqual(seen_indices, set(range(16))) + + def test_choose_tablet_version_block_matches_server_for_signed_version(self): + """tablet_version is decoded as a *signed* 64-bit int (LongType), so a + version with the high bit set is stored negative in the driver while the + server treats it as unsigned. The block the driver emits must still match + the server's check computed on the unsigned value (sign-boundary guard).""" + for unsigned in (0x8000000000000000, 0xDEADBEEFCAFEBABE, 0xFFFFFFFFFFFFFFFF): + signed = unsigned - (1 << 64) # how LongType stores a high-bit value + self.assertLess(signed, 0) + # Sample enough times to exercise every one of the 16 block indices. + for _ in range(256): + block = choose_tablet_version_block(signed) + self.assertTrue( + self._server_block_matches(unsigned, block), + f"signed version {signed} (unsigned 0x{unsigned:016X}) produced " + f"block 0x{block:02X} that failed the server check") + + def test_random_tablet_version_block_returns_byte(self): + """Verify random_tablet_version_block returns a value in [0, 255].""" + for _ in range(100): + block = random_tablet_version_block() + self.assertIsInstance(block, int) + self.assertGreaterEqual(block, 0) + self.assertLessEqual(block, 255) + + def test_from_row_stores_tablet_version(self): + """Tablet.from_row stores the tablet_version it is given (the V2 payload field).""" + version = 0xDEADBEEFCAFEBABE + tablet = Tablet.from_row(-100, 100, [("host1", 0), ("host2", 1)], tablet_version=version) + self.assertIsNotNone(tablet) + self.assertEqual(tablet.tablet_version, version) + self.assertEqual(tablet.first_token, -100) + self.assertEqual(tablet.last_token, 100) + self.assertEqual(len(tablet.replicas), 2) From 66cea51bab9e95adf16528a56df0e49e3b673eed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20M=C4=99drek?= Date: Wed, 8 Jul 2026 22:44:45 +0200 Subject: [PATCH 125/200] Compute and serialize the tablet_version_block per EXECUTE With TABLETS_ROUTING_V2 the server returns, on a tablet_version mismatch, the tablet's replica set plus the new tablet_version, so the driver can keep its routing cache fresh without the per-response overhead v1 incurs. * Every EXECUTE on a V2 connection carries a tablet_version_block computed from the cached version, or a random byte when the table is known but its tablet or version is not (a cold cache, a vnode table), which the server answers with fresh routing info. The block is 0 when the driver cannot resolve the request to a tablet at all: a non-token-aware request, which the server never version-checks, and one whose keyspace or table is unknown, where a payload could not be cached anyway and generating a random byte would be wasted work. * The routing key and its ring token are resolved once per request, in _create_response_future, and handed to both consumers that need them while sending: the tablet_version_block here and shard selection in HostConnection. This keeps cluster-dependent state off the statement, which a caller may share between concurrent requests. The cached tablet is likewise looked up once -- the cache is mutable, so a second lookup could disagree with the first. * Hashing the routing key is guarded by can_support_partitioner(). On a Murmur3 cluster whose murmur3 helper is unavailable, from_key() raises NoMurmur3, and the default load balancing policy drops token awareness entirely; this path runs regardless of the policy in use, so without the check it would raise on every prepared-statement execution. With no token the pool skips shard selection instead of retrying the hash. * On the response, the routing payload is parsed according to what the serving connection negotiated; the v2 tuple additionally carries the tablet_version, which is stored back on the tablet. The tablet is cached under the effective keyspace -- the statement's, else the session's -- so a prepared statement executed in a session keyspace lands under the same key the send path looks it up by. * HostConnection.tablets_routing_v1 becomes supports_tablet_routing: shard selection is identical under both versions, since the request goes to this host either way and the pool picks the shard this host owns for the tablet. Refs: SCYLLADB-288 Refs: SCYLLADB-291 --- cassandra/cluster.py | 157 ++++++++++++++++++++++++----- cassandra/pool.py | 33 ++++-- tests/unit/test_response_future.py | 21 ++-- tests/unit/test_tablets.py | 63 ++++++++++++ 4 files changed, 232 insertions(+), 42 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 751f5e34ff..bcc7852c33 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -69,7 +69,7 @@ RESULT_KIND_SET_KEYSPACE, RESULT_KIND_ROWS, RESULT_KIND_SCHEMA_CHANGE, ProtocolHandler, RESULT_KIND_VOID, ProtocolException) -from cassandra.metadata import Metadata, protect_name, murmur3, _NodeInfo +from cassandra.metadata import Metadata, Token, protect_name, murmur3, _NodeInfo from cassandra.policies import (TokenAwarePolicy, DCAwareRoundRobinPolicy, SimpleConvictionPolicy, ExponentialReconnectionPolicy, HostDistance, RetryPolicy, IdentityTranslator, NoSpeculativeExecutionPlan, @@ -83,7 +83,7 @@ named_tuple_factory, dict_factory, tuple_factory, FETCH_SIZE_UNSET, HostTargetingStatement) from cassandra.marshal import int64_pack -from cassandra.tablets import Tablet +from cassandra.tablets import Tablet, choose_tablet_version_block, random_tablet_version_block from cassandra.timestamps import MonotonicTimestampGenerator from cassandra.util import _resolve_contact_points_to_string_map, Version, maybe_add_timeout_to_query @@ -3050,6 +3050,27 @@ def _create_response_future(self, query, parameters, trace, custom_payload, # bound statements carry cached result metadata (set in the BoundStatement branch). bound_result_metadata = _NOT_SET + # Compute the ring token once, here on the request path, and pass it + # explicitly to the two consumers that run while sending: the + # tablet_version_block below and shard selection in the pool (via the + # ResponseFuture). The token is a pure function of the routing key and + # the cluster's partitioner, so computing it here keeps cluster-dependent + # state off the statement and avoids races when a statement is shared + # across concurrent requests. The load balancing policy computes its own + # token from the same routing key when ordering replicas. + # can_support_partitioner() is what makes this safe to do unconditionally: + # a Murmur3 cluster whose murmur3 helper is unavailable cannot hash a key + # at all (Murmur3Token.hash_fn raises NoMurmur3), and the default load + # balancing policy drops token awareness in that case. Without the check + # this path would raise on every prepared-statement execution instead. + routing_token = None + routing_key = query.routing_key + if routing_key is not None: + metadata = self.cluster.metadata + token_map = metadata.token_map + if token_map is not None and metadata.can_support_partitioner(): + routing_token = token_map.token_class.from_key(routing_key) + if isinstance(query, SimpleStatement): query_string = query.query_string statement_keyspace = query.keyspace if ProtocolVersion.uses_keyspace_flag(self._protocol_version) else None @@ -3075,13 +3096,19 @@ def _create_response_future(self, query, parameters, trace, custom_payload, # decode page 2+ against. result_metadata, result_metadata_id = prepared_statement.result_metadata_and_id bound_result_metadata = result_metadata + + # The tablet_version_block value is connection-independent, so compute + # it once here instead of copying the message per send attempt. The + # serializer emits it only when the serving connection negotiated + # TABLETS_ROUTING_V2 (see ExecuteMessage.send_body). message = ExecuteMessage( prepared_statement.query_id, query.values, cl, serial_cl, fetch_size, paging_state, timestamp, skip_meta=bool(result_metadata) and result_metadata_id is not None and continuous_paging_options is None, continuous_paging_options=continuous_paging_options, - result_metadata_id=result_metadata_id) + result_metadata_id=result_metadata_id, + tablet_version_block=self._compute_tablet_version_block(query, routing_key, routing_token)) elif isinstance(query, BatchStatement): if self._protocol_version < 2: raise UnsupportedOperation( @@ -3108,7 +3135,62 @@ def _create_response_future(self, query, parameters, trace, custom_payload, self, message, query, timeout, metrics=self._metrics, prepared_statement=prepared_statement, retry_policy=retry_policy, row_factory=row_factory, load_balancer=load_balancing_policy, start_time=start_time, speculative_execution_plan=spec_exec_plan, - continuous_paging_state=None, host=host, bound_result_metadata=bound_result_metadata) + continuous_paging_state=None, host=host, bound_result_metadata=bound_result_metadata, + routing_token=routing_token) + + def _compute_tablet_version_block(self, query, routing_key: Optional[bytes], + routing_token: Optional[Token]) -> int: + """ + Compute the tablet_version_block byte for a BoundStatement. + + Always returns an int in [0, 255]. A non-token-aware query (no routing + key) can never resolve to a tablet, so the server never version-checks + it; we send 0 and skip the work. Otherwise, when no cached tablet is + known for the routing key (unknown keyspace/table, vnode table, cold + cache, or a missing token map) a random block is returned; the server + treats that as a version miss and replies with fresh routing info. + + ``routing_key`` and ``routing_token`` are the statement's routing key and + the ring token derived from it, both resolved once per request by the + caller (see :meth:`_create_response_future`) and passed in so the send + path has a single source of truth for them. ``routing_token`` is ``None`` + both when there is no routing key and when no token map was available, so + telling those two cases apart needs the routing key as well -- taking it + as an argument rather than re-reading ``query.routing_key`` keeps the two + values here guaranteed to describe the same statement. + + This is computed once per request at message construction; the value is + connection-independent, and the serializer emits it only on connections + that negotiated TABLETS_ROUTING_V2 (see ExecuteMessage.send_body). + """ + if routing_key is None: + # Non-token-aware query: the server won't version-check it, so skip + # generating random bits and just send 0. + return 0 + + keyspace = query.keyspace or self.keyspace + table = query.table + if not keyspace or not table: + # We don't even know which table we're targeting. Don't waste + # CPU cycles on generating a random byte. + return 0 + + if routing_token is None: + # We're targeting a specific partition of some table, + # so the returned routing information can still be + # useful. Make it possible to obtain it. + return random_tablet_version_block() + + # A single lookup: get_tablet_for_key already reports a table with no + # cached tablets (a vnode table, or a tablet table on cold start) as + # None, and going through the mutable cache twice would leave a window + # for the tablet to disappear between the checks. + tablet = self.cluster.metadata._tablets.get_tablet_for_key(keyspace, table, routing_token) + if tablet is None or tablet.tablet_version is None: + # A version miss on the server, which replies with fresh routing info. + return random_tablet_version_block() + + return choose_tablet_version_block(tablet.tablet_version) def get_execution_profile(self, name): """ @@ -3786,7 +3868,6 @@ class PeersQueryType(object): _schema_meta_page_size = 1000 _uses_peers_v2 = True - _tablets_routing_v1 = False # for testing purposes _time = time @@ -3920,8 +4001,6 @@ def _try_connect(self, endpoint): self._metadata_request_timeout = None if connection.features.sharding_info is None or not self._cluster.metadata_request_timeout \ else datetime.timedelta(seconds=self._cluster.metadata_request_timeout) - self._tablets_routing_v1 = connection.features.tablets_routing_v1 - # use weak references in both directions # _clear_watcher will be called when this ControlConnection is about to be finalized # _watch_callback will get the actual callback from the Connection and relay it to @@ -4735,6 +4814,7 @@ class ResponseFuture(object): _host = None _control_connection_query_attempted = False _TABLET_ROUTING_CTYPE = None + _TABLET_ROUTING_V2_CTYPE = None _bound_result_metadata = None _warned_timeout = False @@ -4742,7 +4822,7 @@ class ResponseFuture(object): def __init__(self, session, message, query, timeout, metrics=None, prepared_statement=None, retry_policy=RetryPolicy(), row_factory=None, load_balancer=None, start_time=None, speculative_execution_plan=None, continuous_paging_state=None, host=None, - bound_result_metadata=_NOT_SET): + bound_result_metadata=_NOT_SET, routing_token=None): self.session = session # TODO: normalize handling of retry policy and row factory self.row_factory = row_factory or session.row_factory @@ -4762,6 +4842,7 @@ def __init__(self, session, message, query, timeout, metrics=None, prepared_stat self._callback_lock = Lock() self._start_time = start_time or time.time() self._host = host + self._routing_token = routing_token self._control_connection_query_attempted = False self._spec_execution_plan = speculative_execution_plan or self._spec_execution_plan self._make_query_plan() @@ -5032,7 +5113,12 @@ def _query(self, host, message=None, cb=None): try: # TODO get connectTimeout from cluster settings if self.query: - connection, request_id = pool.borrow_connection(timeout=2.0, routing_key=self.query.routing_key, keyspace=self.query.keyspace, table=self.query.table) + # Pass the ring token computed once for this request so the pool + # can select the shard without re-hashing the routing key. + connection, request_id = pool.borrow_connection( + timeout=2.0, routing_key=self.query.routing_key, + keyspace=self.query.keyspace, table=self.query.table, + routing_token=self._routing_token) else: connection, request_id = pool.borrow_connection(timeout=2.0) self._connection = connection @@ -5143,6 +5229,27 @@ def _reprepare(self, prepare_message, host, connection, pool): # try to submit the original prepared statement on some other host self.send_request() + def _cache_tablet_from_payload(self, payload_key, ctype): + """ + Parse a tablets-routing ``custom_payload`` entry and cache the Tablet. + + ``ctype`` is the tuple type for the negotiated extension. The V1 and V2 + layouts differ only by a trailing ``tablet_version`` field, and + ``Tablet.from_row`` accepts that as an optional final argument, so + unpacking the decoded tuple positionally serves both. The tablet is + cached under the effective keyspace (the statement's, else the + session's) so a prepared statement executed in a session keyspace lands + under the same key ``_compute_tablet_version_block`` looks it up by; + otherwise that lookup always misses. + """ + info = self._custom_payload.get(payload_key) + protocol = self.session.cluster.protocol_version + tablet = Tablet.from_row(*ctype.from_binary(info, protocol)) + keyspace = self.query.keyspace or self.session.keyspace + table = self.query.table + if tablet and keyspace and table: + self.session.cluster.metadata._tablets.add_tablet(keyspace, table, tablet) + def _set_result(self, host, connection, pool, response): try: self.coordinator_host = host @@ -5158,21 +5265,23 @@ def _set_result(self, host, connection, pool, response): self._warnings = getattr(response, 'warnings', None) self._custom_payload = getattr(response, 'custom_payload', None) - if self._custom_payload and self.session.cluster.control_connection._tablets_routing_v1 and 'tablets-routing-v1' in self._custom_payload: - protocol = self.session.cluster.protocol_version - info = self._custom_payload.get('tablets-routing-v1') - ctype = ResponseFuture._TABLET_ROUTING_CTYPE - if ctype is None: - ctype = types.lookup_casstype('TupleType(LongType, LongType, ListType(TupleType(UUIDType, Int32Type)))') - ResponseFuture._TABLET_ROUTING_CTYPE = ctype - tablet_routing_info = ctype.from_binary(info, protocol) - first_token = tablet_routing_info[0] - last_token = tablet_routing_info[1] - tablet_replicas = tablet_routing_info[2] - tablet = Tablet.from_row(first_token, last_token, tablet_replicas) - keyspace = self.query.keyspace - table = self.query.table - self.session.cluster.metadata._tablets.add_tablet(keyspace, table, tablet) + if self._custom_payload and connection is not None: + # Parse the routing payload according to what the connection that + # *served this request* negotiated, not the control connection: + # different nodes may negotiate different extensions, and each + # payload key matches the extension its own connection negotiated. + if connection.features.tablets_routing_v2 and 'tablets-routing-v2' in self._custom_payload: + ctype = ResponseFuture._TABLET_ROUTING_V2_CTYPE + if ctype is None: + ctype = types.lookup_casstype('TupleType(LongType, LongType, ListType(TupleType(UUIDType, Int32Type)), LongType)') + ResponseFuture._TABLET_ROUTING_V2_CTYPE = ctype + self._cache_tablet_from_payload('tablets-routing-v2', ctype) + elif connection.features.tablets_routing_v1 and 'tablets-routing-v1' in self._custom_payload: + ctype = ResponseFuture._TABLET_ROUTING_CTYPE + if ctype is None: + ctype = types.lookup_casstype('TupleType(LongType, LongType, ListType(TupleType(UUIDType, Int32Type)))') + ResponseFuture._TABLET_ROUTING_CTYPE = ctype + self._cache_tablet_from_payload('tablets-routing-v1', ctype) if isinstance(response, ResultMessage): if response.kind == RESULT_KIND_SET_KEYSPACE: diff --git a/cassandra/pool.py b/cassandra/pool.py index 176751f60a..9515175448 100644 --- a/cassandra/pool.py +++ b/cassandra/pool.py @@ -389,7 +389,7 @@ class HostConnection(object): # the number below, all excess connections will be closed. max_excess_connections_per_shard_multiplier = 3 - tablets_routing_v1 = False + supports_tablet_routing = False def __init__(self, host, host_distance, session): self.host = host @@ -436,11 +436,13 @@ def __init__(self, host, host_distance, session): if first_connection.features.sharding_info and not self._session.cluster.shard_aware_options.disable: self.host.sharding_info = first_connection.features.sharding_info self._open_connections_for_all_shards(first_connection.features.shard_id) - self.tablets_routing_v1 = first_connection.features.tablets_routing_v1 + + self.supports_tablet_routing = first_connection.features.tablets_routing_v1 \ + or first_connection.features.tablets_routing_v2 log.debug("Finished initializing connection for host %s", self.host) - def _get_connection_for_routing_key(self, routing_key=None, keyspace=None, table=None): + def _get_connection_for_routing_key(self, routing_key=None, keyspace=None, table=None, routing_token=None): if self.is_shutdown: raise ConnectionException( "Pool for %s is shutdown" % (self.host,), self.host) @@ -450,22 +452,31 @@ def _get_connection_for_routing_key(self, routing_key=None, keyspace=None, table shard_id = None if not self._session.cluster.shard_aware_options.disable and self.host.sharding_info and routing_key: - t = self._session.cluster.metadata.token_map.token_class.from_key(routing_key) - - shard_id = None - if self.tablets_routing_v1 and table is not None: + # Reuse the token computed once for this request when available, so + # the routing-key hash runs once per request instead of again here; + # fall back to hashing the routing key directly otherwise. The caller + # leaves the token unset when the cluster's partitioner cannot be + # hashed (see Session._create_response_future), so the fallback has to + # make the same check rather than retry a hash that would raise. + metadata = self._session.cluster.metadata + t = routing_token + if t is None and metadata.token_map is not None and metadata.can_support_partitioner(): + t = metadata.token_map.token_class.from_key(routing_key) + if t is not None and self.supports_tablet_routing and table is not None: if keyspace is None: keyspace = self._keyspace tablet = self._session.cluster.metadata._tablets.get_tablet_for_key(keyspace, table, t) + # In both V1 and V2 the request is sent to this host, so we pick + # the shard that this host owns for the tablet. if tablet is not None: for replica in tablet.replicas: if replica[0] == self.host.host_id: shard_id = replica[1] break - if shard_id is None: + if shard_id is None and t is not None: shard_id = self.host.sharding_info.shard_id_from_token(t.value) conn = self._connections.get(shard_id) @@ -506,15 +517,15 @@ def _get_connection_for_routing_key(self, routing_key=None, keyspace=None, table return random.choice(active_connections) return random.choice(list(self._connections.values())) - def borrow_connection(self, timeout, routing_key=None, keyspace=None, table=None): - conn = self._get_connection_for_routing_key(routing_key, keyspace, table) + def borrow_connection(self, timeout, routing_key=None, keyspace=None, table=None, routing_token=None): + conn = self._get_connection_for_routing_key(routing_key, keyspace, table, routing_token) start = time.time() remaining = timeout last_retry = False while True: if conn.is_closed: # The connection might have been closed in the meantime - if so, try again - conn = self._get_connection_for_routing_key(routing_key, keyspace, table) + conn = self._get_connection_for_routing_key(routing_key, keyspace, table, routing_token) with conn.lock: if (not conn.is_closed or last_retry) and conn.in_flight < conn.max_request_id: # On last retry we ignore connection status, since it is better to return closed connection than diff --git a/tests/unit/test_response_future.py b/tests/unit/test_response_future.py index 232ecf6585..d71943ec04 100644 --- a/tests/unit/test_response_future.py +++ b/tests/unit/test_response_future.py @@ -41,7 +41,6 @@ class ResponseFutureTests(unittest.TestCase): def make_basic_session(self): s = Mock(spec=Session) s.row_factory = lambda col_names, rows: [(col_names, rows)] - s.cluster.control_connection._tablets_routing_v1 = False s.cluster.allow_control_connection_query_fallback = ControlConnectionQueryFallback.Disabled return s @@ -65,6 +64,11 @@ def make_control_connection(self): connection.is_control_connection = True connection.get_request_id.return_value = 7 connection.send_msg.return_value = 128 + # These tests exercise control-connection query fallback, not tablet + # routing; default the tablet features off so _set_result skips + # tablet-payload parsing for the mocked responses. + connection.features.tablets_routing_v2 = False + connection.features.tablets_routing_v1 = False return connection def make_session(self): @@ -94,7 +98,7 @@ def test_result_message(self): rf.send_request() rf.session._pools.get.assert_called_once_with('ip1') - pool.borrow_connection.assert_called_once_with(timeout=ANY, routing_key=ANY, keyspace=ANY, table=ANY) + pool.borrow_connection.assert_called_once_with(timeout=ANY, routing_key=ANY, keyspace=ANY, table=ANY, routing_token=ANY) connection.send_msg.assert_called_once_with(rf.message, 1, cb=ANY, encoder=ProtocolHandler.encode_message, decoder=ProtocolHandler.decode_message, result_metadata=[]) @@ -138,6 +142,9 @@ def test_schema_change_result(self): kind=RESULT_KIND_SCHEMA_CHANGE, schema_change_event=event_results) connection = Mock() + # Skip tablet-payload parsing for this mocked response/connection pair. + connection.features.tablets_routing_v2 = False + connection.features.tablets_routing_v1 = False rf._set_result(None, connection, None, result) session.submit.assert_called_once_with(ANY, ANY, rf, connection, **event_results) @@ -285,7 +292,7 @@ def test_retry_policy_says_retry(self): rf.send_request() rf.session._pools.get.assert_called_once_with('ip1') - pool.borrow_connection.assert_called_once_with(timeout=ANY, routing_key=ANY, keyspace=ANY, table=ANY) + pool.borrow_connection.assert_called_once_with(timeout=ANY, routing_key=ANY, keyspace=ANY, table=ANY, routing_token=ANY) connection.send_msg.assert_called_once_with(rf.message, 1, cb=ANY, encoder=ProtocolHandler.encode_message, decoder=ProtocolHandler.decode_message, result_metadata=[]) result = Mock(spec=UnavailableErrorMessage, info={}) @@ -304,7 +311,7 @@ def test_retry_policy_says_retry(self): # it should try again with the same host since this was # an UnavailableException rf.session._pools.get.assert_called_with(host) - pool.borrow_connection.assert_called_with(timeout=ANY, routing_key=ANY, keyspace=ANY, table=ANY) + pool.borrow_connection.assert_called_with(timeout=ANY, routing_key=ANY, keyspace=ANY, table=ANY, routing_token=ANY) connection.send_msg.assert_called_with(rf.message, 2, cb=ANY, encoder=ProtocolHandler.encode_message, decoder=ProtocolHandler.decode_message, result_metadata=[]) def test_retry_with_different_host(self): @@ -319,7 +326,7 @@ def test_retry_with_different_host(self): rf.send_request() rf.session._pools.get.assert_called_once_with('ip1') - pool.borrow_connection.assert_called_once_with(timeout=ANY, routing_key=ANY, keyspace=ANY, table=ANY) + pool.borrow_connection.assert_called_once_with(timeout=ANY, routing_key=ANY, keyspace=ANY, table=ANY, routing_token=ANY) connection.send_msg.assert_called_once_with(rf.message, 1, cb=ANY, encoder=ProtocolHandler.encode_message, decoder=ProtocolHandler.decode_message, result_metadata=[]) assert ConsistencyLevel.QUORUM == rf.message.consistency_level @@ -338,7 +345,7 @@ def test_retry_with_different_host(self): # it should try with a different host rf.session._pools.get.assert_called_with('ip2') - pool.borrow_connection.assert_called_with(timeout=ANY, routing_key=ANY, keyspace=ANY, table=ANY) + pool.borrow_connection.assert_called_with(timeout=ANY, routing_key=ANY, keyspace=ANY, table=ANY, routing_token=ANY) connection.send_msg.assert_called_with(rf.message, 2, cb=ANY, encoder=ProtocolHandler.encode_message, decoder=ProtocolHandler.decode_message, result_metadata=[]) # the consistency level should be the same @@ -1055,7 +1062,7 @@ def test_single_host_query_plan_exhausted_after_one_retry(self): # Verify initial request was sent rf.session._pools.get.assert_called_once_with(specific_host) - pool.borrow_connection.assert_called_once_with(timeout=ANY, routing_key=ANY, keyspace=ANY, table=ANY) + pool.borrow_connection.assert_called_once_with(timeout=ANY, routing_key=ANY, keyspace=ANY, table=ANY, routing_token=ANY) connection.send_msg.assert_called_once_with(rf.message, 1, cb=ANY, encoder=ProtocolHandler.encode_message, decoder=ProtocolHandler.decode_message, result_metadata=[]) # Simulate a ServerError response (which triggers RETRY_NEXT_HOST by default) diff --git a/tests/unit/test_tablets.py b/tests/unit/test_tablets.py index 87478af46e..f77d163eb8 100644 --- a/tests/unit/test_tablets.py +++ b/tests/unit/test_tablets.py @@ -1,5 +1,9 @@ import unittest +from io import BytesIO +from cassandra import ConsistencyLevel, ProtocolVersion +from cassandra.protocol import ExecuteMessage +from cassandra.protocol_features import ProtocolFeatures from cassandra.tablets import Tablets, Tablet, choose_tablet_version_block, random_tablet_version_block class TabletsTest(unittest.TestCase): @@ -189,3 +193,62 @@ def test_from_row_stores_tablet_version(self): self.assertEqual(tablet.first_token, -100) self.assertEqual(tablet.last_token, 100) self.assertEqual(len(tablet.replicas), 2) + + +class ExecuteMessageSerializationTest(unittest.TestCase): + """ExecuteMessage.send_body decides whether to emit the tablet_version_block + from the serving connection's negotiated protocol features, so the message no + longer has to be copied per send attempt (TABLETS_ROUTING_V2).""" + + # V4 keeps the encoding minimal: no prepared-metadata id, single-byte flags. + PROTOCOL_VERSION = ProtocolVersion.V4 + + def _make_message(self, tablet_version_block): + return ExecuteMessage( + query_id=b"\x01\x02\x03\x04", + query_params=[], + consistency_level=ConsistencyLevel.ONE, + tablet_version_block=tablet_version_block, + ) + + def _encode_body(self, message, protocol_features): + f = BytesIO() + message.send_body(f, self.PROTOCOL_VERSION, protocol_features) + return f.getvalue() + + def test_block_appended_on_v2_connection(self): + """A V2 connection appends exactly one trailing byte carrying the + precomputed tablet_version_block.""" + message = self._make_message(0x7A) + v2 = self._encode_body(message, ProtocolFeatures(tablets_routing_v2=True)) + plain = self._encode_body(message, ProtocolFeatures(tablets_routing_v2=False)) + self.assertEqual(v2, plain + bytes([0x7A])) + + def test_block_absent_without_v2(self): + """No trailing byte when the connection did not negotiate V2, and passing + no features at all is equivalent to V2 being off.""" + message = self._make_message(0x7A) + no_features = self._encode_body(message, None) + v2_off = self._encode_body(message, ProtocolFeatures(tablets_routing_v2=False)) + self.assertEqual(no_features, v2_off) + self.assertNotIn(bytes([0x7A]), no_features[-1:]) + + def test_missing_block_coalesces_to_zero_on_v2(self): + """On a V2 connection the server reads exactly one trailing byte per + EXECUTE, so a message whose block was never computed must still emit a + zero byte to keep the frame in sync.""" + message = self._make_message(None) + v2 = self._encode_body(message, ProtocolFeatures(tablets_routing_v2=True)) + plain = self._encode_body(message, ProtocolFeatures(tablets_routing_v2=False)) + self.assertEqual(v2, plain + bytes([0x00])) + + def test_same_message_encodes_consistently_across_connections(self): + """The same shared message instance yields the V2 or non-V2 framing purely + from the features argument, so it is safe to encode concurrently on + connections with different capabilities without copying it.""" + message = self._make_message(0x3C) + first = self._encode_body(message, ProtocolFeatures(tablets_routing_v2=True)) + second_plain = self._encode_body(message, ProtocolFeatures(tablets_routing_v2=False)) + first_again = self._encode_body(message, ProtocolFeatures(tablets_routing_v2=True)) + self.assertEqual(first, first_again) + self.assertEqual(first, second_plain + bytes([0x3C])) From fed5448a787e537c4bf89f6554d6c6a03cda4b11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20M=C4=99drek?= Date: Wed, 8 Jul 2026 16:25:47 +0200 Subject: [PATCH 126/200] Add integration tests for TABLETS_ROUTING_V2 Cover the end-to-end behaviour against a live ScyllaDB started with the `strongly-consistent-tables` experimental feature: v2 negotiation, payload-driven cache population, and the tablet_version_block matching rules (no payload on a matching block, exactly one matching value per index, and v2 taking precedence over v1 on a wrong-shard request). The last of those needs a connection that negotiated both extensions, which the driver never does on its own, so the test patches ProtocolFeatures.add_startup_options. The patch delegates to the real implementation and only adds v1 on top. Enumerating the options itself would silently stop requesting any extension added later while ProtocolFeatures still reported it as negotiated -- that is parsed from SUPPORTED, not from what STARTUP asked for -- and an extension that changes the frame layout, such as SCYLLA_USE_METADATA_ID, would then desynchronize every request on the connection. --- .../standard/test_tablets_routing_v2.py | 407 ++++++++++++++++++ 1 file changed, 407 insertions(+) create mode 100644 tests/integration/standard/test_tablets_routing_v2.py diff --git a/tests/integration/standard/test_tablets_routing_v2.py b/tests/integration/standard/test_tablets_routing_v2.py new file mode 100644 index 0000000000..22a53ed79d --- /dev/null +++ b/tests/integration/standard/test_tablets_routing_v2.py @@ -0,0 +1,407 @@ +""" +End-to-end tests for TABLETS_ROUTING_V2 against a V2-capable Scylla build. + +Unlike the unit tests in tests/unit/test_tablets.py and tests/unit/test_policies.py, +these tests cross the driver<->server boundary: they validate that the driver +negotiates the extension, parses the server's `tablets-routing-v2` payload with +the correct field layout, and that the tablet_version_block it sends actually +matches the server's encoding. + +The whole module is opt-in: the server only advertises the extension when started +with the `strongly-consistent-tables` experimental feature, and it is exchanged on +the wire under the name `TABLETS_ROUTING_V2_EXPERIMENTAL`. When run against a +server that does not advertise it (e.g. a released Scylla), every test is skipped. +""" + +from contextlib import contextmanager + +import pytest + +import cassandra.cqltypes as types +from cassandra import ConsistencyLevel +from cassandra.cluster import Cluster, ExecutionProfile, EXEC_PROFILE_DEFAULT +from cassandra.policies import ConstantReconnectionPolicy, RoundRobinPolicy, TokenAwarePolicy +from cassandra.protocol import ExecuteMessage +from cassandra.protocol_features import ( + ProtocolFeatures, TABLETS_ROUTING_V1, TABLETS_ROUTING_V2, +) + +from tests.integration import PROTOCOL_VERSION, use_cluster + + +def setup_module(module): + try: + # Use a single DC with three racks. + use_cluster('tablets_routing_v2', {"dc1": [1, 1, 1]}, start=True, set_keyspace=False, + configuration_options={ + # `strongly-consistent-tables` is what gates the server's + # advertisement of TABLETS_ROUTING_V2_EXPERIMENTAL. + 'experimental_features': ['udf', 'strongly-consistent-tables'], + }) + except Exception as exc: + pytest.skip("Could not start a Scylla cluster with the " + f"'strongly-consistent-tables' experimental feature: {exc}", + allow_module_level=True) + + +_add_startup_options = ProtocolFeatures.add_startup_options + + +def _startup_with_both_extensions(self, options): + """ + Drop-in replacement for ProtocolFeatures.add_startup_options that negotiates + BOTH tablets_routing_v1 and tablets_routing_v2 on the same connection. + + The real driver makes the two mutually exclusive (V2 wins). Forcing both lets + us prove the server-side precedence rules: scylla checks V2 first and only + falls back to V1 when V2 is not set. + + Every other extension must be negotiated exactly as the driver would, so this + delegates to the real implementation and only adds V1 on top of the V2 it + already requested. Enumerating the options here instead would silently stop + requesting any extension added later, while ProtocolFeatures still reports it + as negotiated (it is parsed from SUPPORTED, not from what STARTUP asked for) -- + and an extension that changes the frame layout, such as + SCYLLA_USE_METADATA_ID, then desynchronizes every request on the connection. + """ + _add_startup_options(self, options) + if self.tablets_routing_v1: + options[TABLETS_ROUTING_V1] = "" + + +class TestTabletsRoutingV2Integration: + @classmethod + def setup_class(cls): + cls.cluster = Cluster(contact_points=["127.0.0.1", "127.0.0.2", "127.0.0.3"], + protocol_version=PROTOCOL_VERSION, + execution_profiles={ + EXEC_PROFILE_DEFAULT: ExecutionProfile( + load_balancing_policy=TokenAwarePolicy(RoundRobinPolicy())) + }, + reconnection_policy=ConstantReconnectionPolicy(1)) + # pytest does not call teardown_class when setup_class raises, so any exit + # from here must shut the Cluster down explicitly or it leaks threads and + # sockets into later test modules. + try: + cls.session = cls.cluster.connect() + # A server without the 'strongly-consistent-tables' experimental + # feature (e.g. a released Scylla) still starts and connects, but it + # neither advertises TABLETS_ROUTING_V2 nor accepts the + # `consistency = 'global'` keyspace that _create_schema needs. Detect + # that here and skip the whole class, instead of letting _create_schema + # fail and erroring every test. + v2_negotiated = cls._v2_negotiated() + if v2_negotiated: + cls._create_schema(cls.session) + except Exception: + cls.cluster.shutdown() + raise + if not v2_negotiated: + cls.cluster.shutdown() + pytest.skip("Server does not support TABLETS_ROUTING_V2_EXPERIMENTAL. " + "It must be started with the 'strongly-consistent-tables' feature " + "and offer support for the protocol extension.") + + @classmethod + def teardown_class(cls): + cls.cluster.shutdown() + + @classmethod + def _create_schema(cls, session): + session.execute("DROP KEYSPACE IF EXISTS test_v2") + session.execute( + """ + CREATE KEYSPACE test_v2 + WITH replication = {'class': 'NetworkTopologyStrategy', 'replication_factor': 2} + AND tablets = {'initial': 8} + """) + session.execute("CREATE TABLE test_v2.t (pk int PRIMARY KEY, v int)") + prepared = session.prepare("INSERT INTO test_v2.t (pk, v) VALUES (?, ?)") + for i in range(50): + session.execute(prepared.bind((i, i))) + + # -- helpers ---------------------------------------------------------------- + + @classmethod + def _v2_negotiated(cls): + connection = cls.session.cluster.control_connection._connection + return bool(connection and connection.features.tablets_routing_v2) + + def _cached_tablet(self, bound): + md = self.session.cluster.metadata + token = md.token_map.token_class.from_key(bound.routing_key) + tablet = md._tablets.get_tablet_for_key(bound.keyspace, bound.table, token) + return tablet, token + + def _ensure_cached(self, bound, attempts=30): + """ + Drive requests until the V2 routing cache is populated for `bound`. + + On a cold start the driver sends a *random* tablet_version_block, which + only matches the server ~1/16 of the time; on a mismatch the server + returns routing info and the cache is filled. We retry until that happens. + """ + for _ in range(attempts): + self.session.execute(bound) + tablet, _token = self._cached_tablet(bound) + if tablet is not None and tablet.tablet_version is not None: + return tablet + raise AssertionError("V2 routing cache was never populated; the server " + "never returned a 'tablets-routing-v2' payload") + + # -- tests ------------------------------------------------------------------ + + def test_v2_is_negotiated(self): + # Every per-host pool must report tablet-routing support, and every live + # connection in it must have negotiated V2 -- V2 gates the per-connection + # EXECUTE framing. + for pool in self.session._pools.values(): + assert pool.supports_tablet_routing is True + for conn in pool._connections.values(): + assert conn.features.tablets_routing_v2 is True + + def test_v2_payload_populates_cache_with_valid_fields(self): + """Test guarding against the payload tuple being decoded out of order.""" + select = self.session.prepare("SELECT v FROM test_v2.t WHERE pk = ?") + bound = select.bind([2]) + + tablet = self._ensure_cached(bound) + _, token = self._cached_tablet(bound) + + # If the tuple were decoded in the wrong order, first_token/last_token + # would actually carry the version / replica list and these invariants + # would not hold. + assert tablet.tablet_version is not None + # tablet_version is an unsigned 64-bit value; a signedness bug in the + # decode path would surface here as a negative or out-of-range number. + assert 0 <= tablet.tablet_version <= 2 ** 64 - 1 + assert tablet.first_token <= tablet.last_token + # get_tablet_for_key matches first_token < token <= last_token. + assert tablet.first_token < token.value and token.value <= tablet.last_token + + # Replicas must be real hosts known to the cluster with sane shard ids. + known_host_ids = {h.host_id for h in self.session.cluster.metadata.all_hosts()} + assert tablet.replicas, "tablet has no replicas" + for host_id, shard in tablet.replicas: + assert host_id in known_host_ids, \ + f"replica host_id {host_id} is not a known host (corrupt payload?)" + assert isinstance(shard, int) and shard >= 0 + + def test_matching_block_yields_no_payload(self): + """Test guarding against a wrong tablet_version_block bit-shift.""" + select = self.session.prepare("SELECT v FROM test_v2.t WHERE pk = ?") + bound = select.bind([7]) + + # Populate the cache so the driver knows the current tablet_version. + self._ensure_cached(bound) + + # The next request carries a block derived from the cached version. If the + # driver's encoding agrees with the server, the versions match and NO + # routing payload is returned. A wrong shift would mismatch and the server + # would keep returning routing info. + result = self.session.execute(bound) + assert result.one() is not None + payload = result.response_future.custom_payload + assert not (payload and 'tablets-routing-v2' in payload), ( + "Server returned routing info despite a cached, up-to-date " + "tablet_version; the driver's tablet_version_block encoding likely " + "disagrees with the server (locator::compare_tablet_version_block)") + + # -- low-level helpers ------------------------------------------------------ + + @staticmethod + def _right_block(version, idx=0): + """ + Build a tablet_version_block that the server will accept as a match for + block `idx` of `version` (high nibble = index, low nibble = that nibble + of the version). + """ + idx &= 0xF + return (idx << 4) | ((version >> (idx * 4)) & 0xF) + + def _send_raw_execute(self, conn, bound, tablet_version_block): + """ + Send an EXECUTE directly on a specific shard connection with a chosen + tablet_version_block (or None to omit the byte entirely, i.e. behave like + the pre-V2 protocol), and return the decoded response message. + + This bypasses ResponseFuture/load balancing so we control exactly which + node+shard the request hits and which byte is on the wire; it also avoids + polluting the driver's tablet cache. + """ + ps = bound.prepared_statement + msg = ExecuteMessage( + ps.query_id, bound.values, ConsistencyLevel.LOCAL_ONE, + serial_consistency_level=None, fetch_size=None, paging_state=None, + timestamp=None, skip_meta=False, + result_metadata_id=ps.result_metadata_id, + tablet_version_block=tablet_version_block) + return conn.wait_for_response(msg, timeout=30) + + def _decode_v2_payload(self, payload): + ctype = types.lookup_casstype( + 'TupleType(LongType, LongType, ListType(TupleType(UUIDType, Int32Type)), LongType)') + info = ctype.from_binary(payload['tablets-routing-v2'], self.cluster.protocol_version) + # LongType decodes as signed, but tablet_version is an unsigned 64-bit + # value; mask it the same way Tablet.from_row does so the decoded value + # matches what the driver cached. + return {'first_token': info[0], 'last_token': info[1], + 'replicas': info[2], + 'tablet_version': info[3] & 0xFFFFFFFFFFFFFFFF} + + def _any_connection(self): + for pool in self.session._pools.values(): + for conn in pool._connections.values(): + return conn + raise AssertionError("no shard connections available") + + @staticmethod + def _all_shard_connections(session): + for host, pool in session._pools.items(): + for shard, conn in pool._connections.items(): + yield host, shard, conn + + @staticmethod + def _wait_for_shard_connections(session, timeout=15): + """Wait until each pool has filled its shard-aware connections (background).""" + import time + deadline = time.time() + timeout + while time.time() < deadline: + if all( + len(pool._connections) >= (min(host.sharding_info.shards_count, 2) + if host.sharding_info else 1) + for host, pool in session._pools.items() + ): + return + time.sleep(0.05) + raise AssertionError(f"Shard-aware connection pools did not fill within {timeout}s") + + @contextmanager + def _cluster_with_v1_and_v2(self): + """ + Yield a (cluster, session) whose connections negotiated BOTH V1 and V2. + Restores the original startup behavior and shuts the cluster down on exit. + """ + original = ProtocolFeatures.add_startup_options + ProtocolFeatures.add_startup_options = _startup_with_both_extensions + + cluster = None + try: + cluster = Cluster(contact_points=["127.0.0.1", "127.0.0.2", "127.0.0.3"], + protocol_version=PROTOCOL_VERSION, + execution_profiles={ + EXEC_PROFILE_DEFAULT: ExecutionProfile( + load_balancing_policy=TokenAwarePolicy(RoundRobinPolicy())) + }, + reconnection_policy=ConstantReconnectionPolicy(1)) + session = cluster.connect('test_v2') + self._wait_for_shard_connections(session) + yield cluster, session + finally: + ProtocolFeatures.add_startup_options = original + if cluster is not None: + cluster.shutdown() + + @staticmethod + def _find_replica_wrong_shard(session, tablet): + """ + Find a connection to a host that *is* a replica of `tablet` but on a shard + that the host does NOT own for it ("right node, wrong shard"). Returns + (host, owner_shard, wrong_shard, conn) or None if no host has >=2 shards. + """ + replica_shard = {host_id: shard for host_id, shard in tablet.replicas} + for host, pool in session._pools.items(): + owner = replica_shard.get(host.host_id) + if owner is None: + continue + for shard, conn in pool._connections.items(): + if shard != owner: + return host, owner, shard, conn + return None + + # -- scenario tests --------------------------------------------------------- + + def test_index0_all_block_values_exactly_one_match(self): + """ + Scenario 1: for block index 0, exactly one of the 16 possible values + matches the server's tablet_version; every other value is reported as a + mismatch carrying that same tablet_version, whose nibble 0 equals the + value that matched. + """ + select = self.session.prepare("SELECT v FROM test_v2.t WHERE pk = ?") + bound = select.bind([11]) + tablet = self._ensure_cached(bound) + version = tablet.tablet_version + + conn = self._any_connection() + + matched_values = [] + reported_versions = [] + for value in range(16): + block = value # index 0 -> high nibble 0, low nibble = value + resp = self._send_raw_execute(conn, bound, block) + payload = resp.custom_payload or {} + if 'tablets-routing-v2' in payload: + reported_versions.append(self._decode_v2_payload(payload)['tablet_version']) + else: + matched_values.append(value) + + # Exactly one value matches: the low nibble of the version. + assert matched_values == [version & 0xF], \ + f"expected exactly one matching block value, got {matched_values}" + # All 15 mismatches report the same tablet_version ... + assert len(reported_versions) == 15 + assert set(reported_versions) == {version} + # ... and that version's block-0 nibble is the value that matched. + assert (version & 0xF) == matched_values[0] + + def test_right_block_to_all_nodes_and_shards_never_returns_payload(self): + """ + Scenario 2: a correct tablet_version_block matches on every node and every + shard (the server's V2 check ignores shard), so no routing payload is ever + returned. + """ + select = self.session.prepare("SELECT v FROM test_v2.t WHERE pk = ?") + bound = select.bind([13]) + tablet = self._ensure_cached(bound) + version = tablet.tablet_version + + sent = 0 + for host, shard, conn in self._all_shard_connections(self.session): + # Vary the block index per shard to also exercise non-zero indices. + block = self._right_block(version, idx=shard) + resp = self._send_raw_execute(conn, bound, block) + payload = resp.custom_payload or {} + assert 'tablets-routing-v2' not in payload, ( + f"host {host} shard {shard} returned a routing payload for a correct " + "tablet_version_block") + sent += 1 + assert sent >= 1, "no shard connections were exercised" + + def test_v2_takes_precedence_over_v1_no_v1_payload_on_wrong_shard(self): + """ + Scenario 3: with BOTH V1 and V2 negotiated, send a correct V2 block to the + wrong shard. The server checks V2 first; since the block matches there is + no payload at all -- crucially no `tablets-routing-v1`, which V1 would have + emitted for a wrong-shard request. + """ + select = self.session.prepare("SELECT v FROM test_v2.t WHERE pk = ?") + bound = select.bind([17]) + tablet = self._ensure_cached(bound) + version = tablet.tablet_version + + with self._cluster_with_v1_and_v2() as (_cluster, session): + target = self._find_replica_wrong_shard(session, tablet) + if target is None: + pytest.skip("need a replica host with >=2 shards to target a wrong shard") + _host, _owner_shard, _wrong_shard, conn = target + assert conn.features.tablets_routing_v1 and conn.features.tablets_routing_v2, \ + "test setup failed: connection did not negotiate both V1 and V2" + + resp = self._send_raw_execute(conn, bound, self._right_block(version)) + payload = resp.custom_payload or {} + assert 'tablets-routing-v1' not in payload, ( + "server emitted V1 routing info despite V2 being negotiated; " + "V2 must take precedence (select_statement.cc)") + # The correct V2 block also means no V2 payload. + assert 'tablets-routing-v2' not in payload From fe9a9171cdbd522165d8be3864f2000c8e18fa3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20M=C4=99drek?= Date: Wed, 8 Jul 2026 16:26:07 +0200 Subject: [PATCH 127/200] Document TABLETS_ROUTING_V2 tablet-version tracking Extend the "Tablet Awareness" section of the Scylla-specific guide to cover the V2 protocol extension: the per-connection negotiation and the tablet_version_block byte that lets the server skip re-sending routing information the driver already has. --- docs/scylla-specific.rst | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/docs/scylla-specific.rst b/docs/scylla-specific.rst index 4f61846b4c..80071d5102 100644 --- a/docs/scylla-specific.rst +++ b/docs/scylla-specific.rst @@ -148,7 +148,7 @@ For more details on paging, see :ref:`query-paging`. Tablet Awareness ---------------- -**scylla-driver** is tablet-aware, which means that it is able to parse `TABLETS_ROUTING_V1` extension to ProtocolFeatures, recieve tablet information sent by Scylla in the `custom_payload` part of the `RESULT` message, and utilize it. +**scylla-driver** is tablet-aware, which means that it is able to parse the `TABLETS_ROUTING_V1` and `TABLETS_ROUTING_V2` extensions to ProtocolFeatures, receive tablet information sent by Scylla in the `custom_payload` part of the `RESULT` message, and utilize it. Thanks to this, queries to tablet-based tables are still shard-aware. Details on the scylla cql protocol extensions @@ -158,6 +158,35 @@ Details on the sending tablet information to the drivers https://github.com/scylladb/scylladb/blob/master/docs/dev/protocol-extensions.md#sending-tablet-info-to-the-drivers +Tablet version tracking +----------------------- + +When the cluster offers it, the driver negotiates ``TABLETS_ROUTING_V2`` in +preference to V1. The negotiation happens per connection, so V2 and V1 +connections can coexist in the same cluster; each connection uses whichever +extension its node offers. V2 adds tablet version tracking on top of V1, +invisible to application code. + +Every tablet now carries a ``tablet_version`` that +changes whenever its replica set is reconfigured. The driver caches the version +it last saw for each tablet and, on every prepared-statement execution over a V2 +connection, appends a single ``tablet_version_block`` byte derived from it. The +server returns updated routing information in the ``custom_payload`` only when +that byte shows the driver's cached view is stale, instead of attaching it to +every response. This keeps the cached routing information fresh while avoiding +the per-response overhead that V1 incurs. + +No configuration is required: as with V1, a ``TokenAwarePolicy`` is all that is +needed. + +.. note:: + + ``TABLETS_ROUTING_V2`` is still experimental: a Scylla node advertises it + (on the wire as ``TABLETS_ROUTING_V2_EXPERIMENTAL``) only when started with + the ``strongly-consistent-tables`` experimental feature enabled. A node + without it offers only ``TABLETS_ROUTING_V1``. + + Prepared Statement Metadata Caching (``SCYLLA_USE_METADATA_ID``) ---------------------------------------------------------------- From f78b4f0a639714b98951d42e5635be7424cdfe88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20M=C4=99drek?= Date: Wed, 8 Jul 2026 16:26:53 +0200 Subject: [PATCH 128/200] Record the private consistency mode on keyspace metadata Add KeyspaceMetadata._consistency_mode, derived from the per-keyspace `consistency` option in system_schema.scylla_keyspaces. It is a _ConsistencyMode enum -- EVENTUAL, LOCAL or GLOBAL -- so the mode the server reported is kept verbatim instead of being flattened into a boolean at parse time. A full refresh reads the option as part of _query_all's batch, so it costs no round trip of its own, and a single-keyspace refresh reads only that keyspace's row. Both degrade to EVENTUAL on a connection that did not negotiate TABLETS_ROUTING_V2 -- which covers non-Scylla clusters, since only Scylla advertises it -- and on Scylla versions that lack the table or column. A transient failure reading the table propagates instead, aborting the refresh so the modes already known are retried, rather than resetting every keyspace to eventual and so silently disabling leader routing and evicting the tablet cache. Scylla only implements `global` so far, so a keyspace's tablets have a Raft leader exactly when its mode is GLOBAL; `local` is reserved for a mode that does not exist yet and behaves like `eventual` everywhere. Callers that care compare against _ConsistencyMode.GLOBAL directly, so implementing `local` later only widens those comparisons and leaves the parser and the metadata untouched. A change of mode also invalidates the keyspace's cached tablets, the same way a replication-strategy change does: a tablet cached while the keyspace was eventually consistent carries no leader ordering and must not survive into a strongly-consistent keyspace, where it would be misread as a leader hint. The mode is also emitted by KeyspaceMetadata.as_cql_query, so a schema dump of a strongly-consistent keyspace recreates it as one instead of silently downgrading it to eventual consistency. Both names are underscore-prefixed to keep them private: they are not yet stable and we do not want to commit to a public API for them. --- cassandra/metadata.py | 145 ++++++++++++++++++++++++++++- tests/unit/test_metadata.py | 180 +++++++++++++++++++++++++++++++++++- 2 files changed, 323 insertions(+), 2 deletions(-) diff --git a/cassandra/metadata.py b/cassandra/metadata.py index 43399b7152..5669e6a80e 100644 --- a/cassandra/metadata.py +++ b/cassandra/metadata.py @@ -16,6 +16,7 @@ from bisect import bisect_left from collections import defaultdict from collections.abc import Mapping +from enum import Enum from functools import total_ordering from hashlib import md5 import json @@ -194,7 +195,8 @@ def _update_keyspace(self, keyspace_meta, new_user_types=None): keyspace_meta.functions = old_keyspace_meta.functions keyspace_meta.aggregates = old_keyspace_meta.aggregates keyspace_meta.views = old_keyspace_meta.views - if (keyspace_meta.replication_strategy != old_keyspace_meta.replication_strategy): + if (keyspace_meta.replication_strategy != old_keyspace_meta.replication_strategy or + keyspace_meta._consistency_mode != old_keyspace_meta._consistency_mode): self._keyspace_updated(ks_name) else: self._keyspace_added(ks_name) @@ -733,6 +735,47 @@ def __eq__(self, other): return isinstance(other, LocalStrategy) +class _ConsistencyMode(Enum): + """ + Per-keyspace consistency option reported by ScyllaDB in + ``system_schema.scylla_keyspaces``. The server represents an + eventually-consistent keyspace as ``'eventual'`` or null. + + ScyllaDB only implements ``GLOBAL`` so far, so it is currently the only mode + under which a keyspace's tablets have a Raft leader. ``LOCAL`` is reserved + for a mode that does not exist yet, and until it does it behaves exactly like + ``EVENTUAL`` everywhere in the driver. + """ + EVENTUAL = 'eventual' + LOCAL = 'local' + GLOBAL = 'global' + + +def _consistency_mode_from_string(value: Optional[str]) -> _ConsistencyMode: + """ + Map the ``consistency`` option ScyllaDB reports to a :class:`._ConsistencyMode`. + + The comparison is case-insensitive, since the option is a free-form string on + the wire. A null value -- which is how the server reports an + eventually-consistent keyspace -- or an unrecognized one mean eventually consistent: + the driver must not refuse to build metadata because a server reported a mode it + does not know. + """ + + # Unfortunately, we need to handle the case of None separately. + # When the code is compiled with Cython, calling lower() on + # None will result in a segmentation fault, not an AttributeError. + # That most likely happens as an optimization based on the type + # hint. + if value is None: + return _ConsistencyMode.EVENTUAL + + try: + return _ConsistencyMode(value.lower()) + except ValueError: + return _ConsistencyMode.EVENTUAL + + class KeyspaceMetadata(object): """ A representation of the schema for a single keyspace. @@ -801,6 +844,15 @@ class KeyspaceMetadata(object): A string indicating whether a graph engine is enabled for this keyspace (Core/Classic). """ + _consistency_mode = _ConsistencyMode.EVENTUAL + """ + The consistency mode of the keyspace, derived from the the ``consistency`` + column ScyllaDB stores in ``system_schema.scylla_keyspaces``. + + Private and unstable: it backs leader-aware routing and is not part of the + public API, so the name and semantics may change. + """ + _exc_info = None """ set if metadata parsing failed """ @@ -815,6 +867,7 @@ def __init__(self, name, durable_writes, strategy_class, strategy_options, graph self.aggregates = {} self.views = {} self.graph_engine = graph_engine + self._consistency_mode = _ConsistencyMode.EVENTUAL @property def is_graph_enabled(self): @@ -861,6 +914,15 @@ def as_cql_query(self): ret = "CREATE KEYSPACE %s WITH replication = %s " % ( protect_name(self.name), self.replication_strategy.export_for_schema()) + + if self._consistency_mode != _ConsistencyMode.EVENTUAL: + # Eventual consistency is the server's default, so it is left out. + # Any other mode is spelled exactly as the server reported it -- that + # string is where the member's value came from -- which keeps this + # correct for a mode added later without touching this method. Note + # that 'local' consistency is not implemented in ScyllaDB yet. + ret = ret + (" AND consistency = '%s'" % self._consistency_mode.value) + ret = ret + (' AND durable_writes = %s' % ("true" if self.durable_writes else "false")) if self.graph_engine is not None: ret = ret + (" AND graph_engine = '%s'" % self.graph_engine) @@ -2577,6 +2639,11 @@ class SchemaParserV3(SchemaParserV22): _SELECT_AGGREGATES = "SELECT * FROM system_schema.aggregates" _SELECT_VIEWS = "SELECT * FROM system_schema.views" + # ScyllaDB-only: per-keyspace consistency option. The column is null for + # eventually-consistent keyspaces (and the whole table is absent on Cassandra + # and on Scylla versions without strongly-consistent tablets). + _SELECT_SCYLLA_KEYSPACES = "SELECT keyspace_name, consistency FROM system_schema.scylla_keyspaces" + def _is_not_scylla(self): """Check if NOT connected to ScyllaDB by checking for shard awareness.""" return getattr(getattr(self.connection, 'features', None), 'shard_id', None) is None @@ -2608,14 +2675,63 @@ def _is_not_scylla(self): def __init__(self, connection, timeout, fetch_size, metadata_request_timeout): super(SchemaParserV3, self).__init__(connection, timeout, fetch_size, metadata_request_timeout) self.indexes_result = [] + self.scylla_keyspaces_result = [] self.keyspace_table_index_rows = defaultdict(lambda: defaultdict(list)) self.keyspace_view_rows = defaultdict(list) + self.keyspace_consistency_modes = {} + + def _tablets_routing_v2_negotiated(self): + """ + Whether this connection negotiated ``TABLETS_ROUTING_V2``. + + The per-keyspace consistency option only feeds V2 leader-aware routing, + so without the extension there is nothing to route for and the option is + not worth a query. This also subsumes a Scylla check: only ScyllaDB + advertises the extension. + """ + features = getattr(self.connection, 'features', None) + return features is not None and getattr(features, 'tablets_routing_v2', False) + + def _query_keyspace_consistency_mode(self, keyspace): + """ + Read one keyspace's consistency mode from + ``system_schema.scylla_keyspaces``. + + Used by the single-keyspace refresh path, which cannot reuse the map + ``_query_all`` builds because it does not run ``_query_all`` at all. The + read is restricted to ``keyspace``, mirroring the filtered query the + superclass uses for the keyspace row itself. + + A keyspace absent from the table, an unrecognized value, and a missing + table or column (older ScyllaDB, which answers InvalidRequest -- + absorbed by _query_build_row) all mean eventually consistent. A transient + failure propagates, which aborts the refresh and leaves the previously + known mode in place to be retried, rather than resetting the keyspace to + eventual and so silently disabling leader routing and evicting its + tablets. + """ + if not self._tablets_routing_v2_negotiated(): + return _ConsistencyMode.EVENTUAL + + where_clause = bind_params(" WHERE keyspace_name = %s", (keyspace,), _encoder) + row = self._query_build_row(self._SELECT_SCYLLA_KEYSPACES + where_clause, lambda row: row) + if row is None: + return _ConsistencyMode.EVENTUAL + return _consistency_mode_from_string(row.get("consistency")) + + def get_keyspace(self, keyspaces, keyspace): + keyspace_meta = super(SchemaParserV3, self).get_keyspace(keyspaces, keyspace) + if keyspace_meta is not None: + keyspace_meta._consistency_mode = self._query_keyspace_consistency_mode(keyspace) + return keyspace_meta def get_all_keyspaces(self): for keyspace_meta in super(SchemaParserV3, self).get_all_keyspaces(): for row in self.keyspace_view_rows[keyspace_meta.name]: view_meta = self._build_view_metadata(row) keyspace_meta._add_view_metadata(view_meta) + keyspace_meta._consistency_mode = self.keyspace_consistency_modes.get( + keyspace_meta.name, _ConsistencyMode.EVENTUAL) yield keyspace_meta def get_table(self, keyspaces, keyspace, table): @@ -2843,6 +2959,14 @@ def _query_all(self): queries.append(QueryMessage(query=maybe_add_timeout_to_query(self._SELECT_TRIGGERS, self.metadata_request_timeout), fetch_size=fetch_size, consistency_level=cl)) + # ScyllaDB-only: the per-keyspace consistency option, which rides along in + # this batch instead of costing a round trip of its own. + scylla_keyspaces_index = None + if self._tablets_routing_v2_negotiated(): + scylla_keyspaces_index = len(queries) + queries.append(QueryMessage(query=maybe_add_timeout_to_query(self._SELECT_SCYLLA_KEYSPACES, self.metadata_request_timeout), + fetch_size=fetch_size, consistency_level=cl)) + responses = self.connection.wait_for_responses(*queries, timeout=self.timeout, fail_on_error=False) # Unpack common responses (always present) @@ -2872,6 +2996,18 @@ def _query_all(self): else: self.triggers_result = [] + if scylla_keyspaces_index is not None: + (scylla_ks_success, scylla_ks_result) = responses[scylla_keyspaces_index] + # An older ScyllaDB may lack the table or the column and answers + # InvalidRequest; that is not an error, it just means no keyspace is + # strongly consistent. Any other failure propagates, aborting the + # refresh so the modes already known are retried rather than reset. + self.scylla_keyspaces_result = self._handle_results( + scylla_ks_success, scylla_ks_result, expected_failures=(InvalidRequest,), + query_msg=queries[scylla_keyspaces_index]) + else: + self.scylla_keyspaces_result = [] + self._aggregate_results() def _aggregate_results(self): @@ -2887,6 +3023,13 @@ def _aggregate_results(self): for row in self.views_result: m[row["keyspace_name"]].append(row) + # A keyspace missing from the result -- including every keyspace when the + # read was skipped or the table was unavailable -- is eventually + # consistent, which get_all_keyspaces applies as the default. + self.keyspace_consistency_modes = { + row["keyspace_name"]: _consistency_mode_from_string(row.get("consistency")) + for row in self.scylla_keyspaces_result} + @staticmethod def _schema_type_to_cql(type_string): return type_string diff --git a/tests/unit/test_metadata.py b/tests/unit/test_metadata.py index 15cf283777..a058f73c61 100644 --- a/tests/unit/test_metadata.py +++ b/tests/unit/test_metadata.py @@ -32,10 +32,12 @@ _UnknownStrategy, ColumnMetadata, TableMetadata, IndexMetadata, Function, Aggregate, Metadata, TokenMap, ReplicationFactor, - SchemaParserDSE68) + SchemaParserDSE68, SchemaParserV3, + _ConsistencyMode, _consistency_mode_from_string) from cassandra.policies import SimpleConvictionPolicy from cassandra.pool import Host from cassandra.protocol import QueryMessage +from cassandra.tablets import Tablet from tests.util import assertCountEqual import pytest @@ -522,6 +524,38 @@ def test_comparison_unicode(self): class KeyspaceMetadataTest(unittest.TestCase): + @staticmethod + def _keyspace(consistency_mode=None): + keyspace = KeyspaceMetadata('test', True, 'NetworkTopologyStrategy', dict(dc1=3)) + if consistency_mode is not None: + keyspace._consistency_mode = consistency_mode + return keyspace + + def test_as_cql_query_omits_eventual_consistency(self): + # Eventual consistency is the server's default, so it must not be + # spelled out -- including for a keyspace whose mode was never set, + # which is every keyspace on a non-Scylla cluster. + assert 'consistency' not in self._keyspace().as_cql_query() + assert 'consistency' not in self._keyspace(_ConsistencyMode.EVENTUAL).as_cql_query() + + def test_as_cql_query_includes_consistency_mode(self): + # A recreated keyspace has to keep its consistency mode, or the copy + # silently loses strong consistency. + assert self._keyspace(_ConsistencyMode.GLOBAL).as_cql_query() == ( + "CREATE KEYSPACE test WITH replication = " + "{'class': 'NetworkTopologyStrategy', 'dc1': '3'} " + " AND consistency = 'global' AND durable_writes = true") + assert self._keyspace(_ConsistencyMode.LOCAL).as_cql_query() == ( + "CREATE KEYSPACE test WITH replication = " + "{'class': 'NetworkTopologyStrategy', 'dc1': '3'} " + " AND consistency = 'local' AND durable_writes = true") + + def test_export_as_string_includes_consistency_mode(self): + # export_as_string() appends the statement terminator and is what a + # schema dump goes through, so the option has to survive that path too. + exported = self._keyspace(_ConsistencyMode.GLOBAL).export_as_string() + assert "AND consistency = 'global' AND durable_writes = true;" in exported + def test_export_as_string_user_types(self): keyspace_name = 'test' keyspace = KeyspaceMetadata(keyspace_name, True, 'NetworkTopologyStrategy', dict(dc1=3)) @@ -552,6 +586,150 @@ def test_export_as_string_user_types(self): );""" == keyspace.export_as_string() +class KeyspaceConsistencyTabletInvalidationTest(unittest.TestCase): + """ + Metadata._update_keyspace must drop cached tablets when a keyspace's + strong-consistency mode changes, not only when its replication strategy + changes. A tablet cached while the keyspace was eventually consistent has no + leader ordering, so it must not survive an eventual->global flip and then be + misread as a leader hint by TokenAwarePolicy.make_query_plan. + """ + + def _ks_meta(self, strongly_consistent): + meta = KeyspaceMetadata('ks', True, 'NetworkTopologyStrategy', {'replication_factor': '1'}) + meta._consistency_mode = _ConsistencyMode.GLOBAL if strongly_consistent else _ConsistencyMode.EVENTUAL + return meta + + def _add_cached_tablet(self, metadata): + tablet = Tablet(first_token=-100, last_token=100, + replicas=[(uuid.uuid4(), 0)], tablet_version=1) + metadata._tablets.add_tablet('ks', 'tbl', tablet) + + def test_consistency_flip_drops_tablets(self): + metadata = Metadata() + metadata._update_keyspace(self._ks_meta(strongly_consistent=False)) + self._add_cached_tablet(metadata) + assert metadata._tablets.table_has_tablets('ks', 'tbl') + + # Same replication strategy, consistency flips False -> True: the stale + # tablet cache must be dropped. + metadata._update_keyspace(self._ks_meta(strongly_consistent=True)) + assert not metadata._tablets.table_has_tablets('ks', 'tbl') + + def test_no_consistency_change_keeps_tablets(self): + metadata = Metadata() + metadata._update_keyspace(self._ks_meta(strongly_consistent=False)) + self._add_cached_tablet(metadata) + assert metadata._tablets.table_has_tablets('ks', 'tbl') + + # No replication change and no consistency change: cache is preserved. + metadata._update_keyspace(self._ks_meta(strongly_consistent=False)) + assert metadata._tablets.table_has_tablets('ks', 'tbl') + + +class ScyllaKeyspaceConsistencyParsingTest(unittest.TestCase): + """ + SchemaParserV3 maps the server's per-keyspace consistency option to + KeyspaceMetadata._consistency_mode, on the bulk path (rows collected by + _query_all, folded into a map by _aggregate_results) and on the + single-keyspace path (a filtered read). A transient failure reading the + consistency table propagates so the schema refresh aborts and the previously + known metadata is retried, rather than being reset to eventual. + + Which modes actually get leader-aware routing is TokenAwarePolicy's business + and is covered in tests/unit/test_policies.py. + """ + + def _parser_with_rows(self, rows): + # Build the parser without a connection and drive only the aggregation + # step; _query_all's batching is exercised by the integration tests. + parser = SchemaParserV3.__new__(SchemaParserV3) + parser.scylla_keyspaces_result = rows + return parser + + def test_consistency_modes_are_mapped_from_rows(self): + # The mode the server reported is kept verbatim, so 'local' stays + # distinguishable from 'eventual' even though ScyllaDB does not implement + # it yet and the driver treats the two alike. + parser = self._parser_with_rows([ + {'keyspace_name': 'g', 'consistency': 'global'}, + {'keyspace_name': 'l', 'consistency': 'local'}, + {'keyspace_name': 'e', 'consistency': 'eventual'}, + {'keyspace_name': 'n', 'consistency': None}, + ]) + modes = {row["keyspace_name"]: _consistency_mode_from_string(row.get("consistency")) + for row in parser.scylla_keyspaces_result} + assert modes['g'] == _ConsistencyMode.GLOBAL + assert modes['l'] == _ConsistencyMode.LOCAL + assert modes['e'] == _ConsistencyMode.EVENTUAL + assert modes['n'] == _ConsistencyMode.EVENTUAL + + def test_keyspace_absent_from_the_map_is_eventual(self): + # Covers the whole-cluster fallbacks too: no rows is what a skipped read + # (no TABLETS_ROUTING_V2) and a missing table/column both produce. + parser = SchemaParserV3.__new__(SchemaParserV3) + parser.keyspace_consistency_modes = {'g': _ConsistencyMode.GLOBAL} + assert parser.keyspace_consistency_modes.get( + 'absent', _ConsistencyMode.EVENTUAL) == _ConsistencyMode.EVENTUAL + + def test_single_keyspace_read_is_filtered_and_mapped(self): + # The single-keyspace refresh path must not read the whole table; it + # restricts the query to the keyspace being refreshed. + parser = SchemaParserV3.__new__(SchemaParserV3) + parser.connection = Mock(features=Mock(tablets_routing_v2=True)) + queries = [] + + def _fake_query_build_row(query_string, build_func): + queries.append(query_string) + return {'keyspace_name': 'g', 'consistency': 'global'} + parser._query_build_row = _fake_query_build_row + + assert parser._query_keyspace_consistency_mode('g') == _ConsistencyMode.GLOBAL + assert len(queries) == 1 + assert "WHERE keyspace_name = 'g'" in queries[0] + + def test_single_keyspace_read_is_skipped_without_v2(self): + # Without the extension there is nothing to route for, so the query is + # not issued at all and the keyspace is eventually consistent. + parser = SchemaParserV3.__new__(SchemaParserV3) + parser.connection = Mock(features=Mock(tablets_routing_v2=False)) + + def _fail(*args, **kwargs): + raise AssertionError("scylla_keyspaces must not be queried without V2") + parser._query_build_row = _fail + + assert parser._query_keyspace_consistency_mode('g') == _ConsistencyMode.EVENTUAL + + def test_consistency_string_mapping_is_case_insensitive(self): + # The option is a free-form string on the wire, so the mapping must not + # depend on the case the server happens to use. Anything unrecognized -- + # including a null, which is how an eventually-consistent keyspace is + # reported -- falls back to eventual rather than failing the refresh. + assert _consistency_mode_from_string('GLOBAL') == _ConsistencyMode.GLOBAL + assert _consistency_mode_from_string('Global') == _ConsistencyMode.GLOBAL + assert _consistency_mode_from_string('global') == _ConsistencyMode.GLOBAL + assert _consistency_mode_from_string('LOCAL') == _ConsistencyMode.LOCAL + assert _consistency_mode_from_string('eventual') == _ConsistencyMode.EVENTUAL + assert _consistency_mode_from_string(None) == _ConsistencyMode.EVENTUAL + assert _consistency_mode_from_string('something-new') == _ConsistencyMode.EVENTUAL + + def test_read_failure_propagates(self): + # A transient failure reading system_schema.scylla_keyspaces must + # propagate (not be swallowed into "eventual"), so the schema refresh + # aborts and the previously known consistency modes are retried. + parser = SchemaParserV3.__new__(SchemaParserV3) + # The control connection must have negotiated V2 to reach the read; + # otherwise the query is skipped and no failure could propagate. + parser.connection = Mock(features=Mock(tablets_routing_v2=True)) + + def _raise_timeout(*args, **kwargs): + raise cassandra.OperationTimedOut("scylla_keyspaces read timed out") + parser._query_build_row = _raise_timeout + + with pytest.raises(cassandra.OperationTimedOut): + parser._query_keyspace_consistency_mode('g') + + class UserTypesTest(unittest.TestCase): def test_as_cql_query(self): From bf9a45397193a6acd2a28c1c09547d6623f0e9cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20M=C4=99drek?= Date: Wed, 8 Jul 2026 16:27:23 +0200 Subject: [PATCH 129/200] Route requests to the tablet leader for strongly-consistent tables For a strongly-consistent tablet the TABLETS_ROUTING_V2 server orders the replica set with the Raft leader first (replicas[0]) and keeps it fresh via the tablet_version already tracked in the previous commits. TokenAwarePolicy uses this to send reads and writes for such tables straight to the leader, saving the extra coordinator->leader hop. Tablet.leader names that ordering in one place, and reports None for a tablet with no replicas so callers do not each have to guard the lookup. The leader is yielded first only when the keyspace's consistency mode is GLOBAL -- the only mode Scylla implements, and so the only one whose tablets have a leader -- and when the tablet carries a tablet_version: eventually-consistent tablet tables are assigned a tablet_version too, and a versionless (v1-sourced or stale) tablet must not be mistaken for a leader hint. Requests at consistency level ONE or LOCAL_ONE are left alone. Any single replica satisfies them, so preferring the leader would only concentrate load on it without buying any consistency. The level is read from the statement, so a request that inherits it from an execution profile looks unset here and is routed to the leader anyway; that costs a little leader contention and nothing in correctness, and is tracked separately in scylladb/python-driver#953. The hint stays bounded by the wrapped policy. Among the hosts that policy is willing to use the leader outranks distance -- a REMOTE leader is yielded before a LOCAL_RACK replica, since every write and linearizable read has to reach the leader anyway and a globally-consistent table gains no consistency from staying in one datacenter. It never overrides the policy's own filter, though: a leader the child policy reports as IGNORED is not contacted, so under the default DCAwareRoundRobinPolicy, which ignores remote hosts, the request goes to a local replica and the server forwards it, exactly as it would without v2. Leader preference can be turned off per policy instance with the private _prefer_tablet_leader option, leaving strongly-consistent tables with plain token-aware ordering. It is private and defaults to on while strong consistency is experimental. Refs: SCYLLADB-288 Fixes: SCYLLADB-291 --- cassandra/policies.py | 104 ++++++++- cassandra/pool.py | 3 +- cassandra/tablets.py | 27 +++ tests/unit/test_policies.py | 423 +++++++++++++++++++++++++++++++++++- tests/unit/test_tablets.py | 27 +++ 5 files changed, 577 insertions(+), 7 deletions(-) diff --git a/cassandra/policies.py b/cassandra/policies.py index 89702e8c89..f1bfefb41d 100644 --- a/cassandra/policies.py +++ b/cassandra/policies.py @@ -464,6 +464,13 @@ class TokenAwarePolicy(LoadBalancingPolicy): If no :attr:`~.Statement.routing_key` is set on the query, the child policy's query plan will be used as is. + + For a table in a strongly-consistent ScyllaDB keyspace, one replica of each + tablet is the Raft leader that coordinates its writes and its linearizable + reads. By default that replica is yielded first -- as long as the child policy + would contact it at all -- so the request reaches it directly instead of being + forwarded there by another coordinator. The private + ``_prefer_tablet_leader`` option turns that off. """ _child_policy = None @@ -473,9 +480,41 @@ class TokenAwarePolicy(LoadBalancingPolicy): Yield local replicas in a random order. """ - def __init__(self, child_policy, shuffle_replicas=True): + _prefer_tablet_leader = True + """ + Yield the Raft leader of a tablet first, for tables in a strongly-consistent + keyspace (one created with ``consistency = 'global'``). Has no effect on + eventually-consistent tables, which have no leader, or on clusters without + the ``TABLETS_ROUTING_V2`` protocol extension, which does not report one. + + The leader outranks distance among the hosts the child policy is willing to + use: it is yielded ahead of nearer replicas, a ``REMOTE`` leader before a + ``LOCAL_RACK`` one included. Every write and every linearizable read on such a + table has to be coordinated by the leader, so contacting a nearer replica only + adds a forwarding hop -- and because the table is globally consistent, keeping + the request inside one datacenter buys no consistency either. + + It does not override the child policy's own filter, though: a leader the child + policy reports as ``IGNORED`` is never contacted. With the default + ``DCAwareRoundRobinPolicy(used_hosts_per_remote_dc=0)`` a leader in a remote + datacenter is ignored, so the request goes to a local replica and the server + forwards it to the leader, exactly as it would without V2. + + Requests at consistency level ``ONE`` or ``LOCAL_ONE`` are exempt regardless + of this setting: any single replica satisfies them, so preferring the leader + would only concentrate load on it. + + Set this to ``False`` to keep plain token-aware ordering for + strongly-consistent tables as well. + + Private and unstable: strong consistency is still experimental, so the name + and the default may change before it is part of the public API. + """ + + def __init__(self, child_policy, shuffle_replicas=True, _prefer_tablet_leader=True): self._child_policy = child_policy self.shuffle_replicas = shuffle_replicas + self._prefer_tablet_leader = _prefer_tablet_leader def populate(self, cluster, hosts): self._cluster_metadata = cluster.metadata @@ -502,15 +541,59 @@ def make_query_plan(self, working_keyspace=None, query=None): yield host return + # Deferred: cassandra.metadata reaches this module through + # cassandra.protocol, so importing it at module scope closes an import + # cycle. Same reason cassandra.connection imports from metadata inside + # its functions. + from cassandra.metadata import _ConsistencyMode + replicas = [] - tablet = self._cluster_metadata._tablets.get_tablet_for_key( - keyspace, query.table, self._cluster_metadata.token_map.token_class.from_key(query.routing_key)) + leader_host = None + token = self._cluster_metadata.token_map.token_class.from_key(query.routing_key) + tablet = self._cluster_metadata._tablets.get_tablet_for_key(keyspace, query.table, token) if tablet is not None: replicas_mapped = set(map(lambda r: r[0], tablet.replicas)) child_plan = child.make_query_plan(keyspace, query) replicas = [host for host in child_plan if host.host_id in replicas_mapped] + + # The leader concept only exists for strongly-consistent keyspaces, + # which today means exactly the keyspaces whose consistency mode is + # GLOBAL: it is the only mode ScyllaDB implements so far, so LOCAL + # (reserved, unimplemented) and EVENTUAL both have no leader. This + # comparison has to widen once 'local' consistency exists. + # TABLETS_ROUTING_V2 assigns a tablet_version to *every* tablet table + # (eventually- and strongly-consistent alike), so the version alone + # must not be used to infer a leader. Conversely, replicas[0] is only + # leader-ordered for a tablet that came from a V2 payload, so a + # versionless tablet (V1-sourced, or stale across a consistency flip) + # must not be treated as a leader hint either. Require both a + # strongly-consistent keyspace and a versioned tablet; otherwise keep + # normal token-aware/shuffled ordering. + ks_meta = self._cluster_metadata.keyspaces.get(keyspace) + if (self._prefer_tablet_leader + and ks_meta is not None and ks_meta._consistency_mode == _ConsistencyMode.GLOBAL + and tablet.tablet_version is not None): + # Even for a leader-eligible tablet, a request at consistency + # level ONE or LOCAL_ONE is satisfied by any single replica, so + # preferring the leader would only concentrate load into a + # hotspot without buying any consistency; spread those instead. + # TODO: This reads the level off the statement, so a request that + # inherits its consistency level from an execution profile + # looks unset here and is routed to the leader anyway. See + # https://github.com/scylladb/python-driver/issues/953 + effective_cl = query.consistency_level + prefer_leader = effective_cl not in (ConsistencyLevel.ONE, ConsistencyLevel.LOCAL_ONE) + if prefer_leader: + leader_host_id = tablet.leader + # A tablet with no replicas reports no leader; guard against + # matching a host whose own host_id is still unknown. + if leader_host_id is not None: + for host in replicas: + if host.host_id == leader_host_id: + leader_host = host + break else: replicas = self._cluster_metadata.get_replicas(keyspace, query.routing_key) @@ -523,9 +606,20 @@ def yield_in_order(hosts): if replica.is_up and child.distance(replica) == distance: yield replica - # yield replicas: local_rack, local, remote - yield from yield_in_order(replicas) + # If we have a leader hint, yield it first -- but respect the child + # policy's own filter: never front-run a host the child policy would + # exclude (e.g. one a custom policy reports as IGNORED). + if (leader_host is not None and leader_host.is_up + and child.distance(leader_host) != HostDistance.IGNORED): + yield leader_host + + # yield replicas: local_rack, local, remote (skipping leader already yielded) + for host in yield_in_order(replicas): + if host is not leader_host: + yield host + # yield rest of the cluster: local_rack, local, remote + # Note: The leader is always a replica, so we don't need to filter it out here. yield from yield_in_order([host for host in child.make_query_plan(keyspace, query) if host not in replicas]) def on_up(self, *args, **kwargs): diff --git a/cassandra/pool.py b/cassandra/pool.py index 9515175448..1d90e3233f 100644 --- a/cassandra/pool.py +++ b/cassandra/pool.py @@ -469,7 +469,8 @@ def _get_connection_for_routing_key(self, routing_key=None, keyspace=None, table tablet = self._session.cluster.metadata._tablets.get_tablet_for_key(keyspace, table, t) # In both V1 and V2 the request is sent to this host, so we pick - # the shard that this host owns for the tablet. + # the shard that this host owns for the tablet. Leader-aware host + # selection (V2) happens earlier, in the load balancing policy. if tablet is not None: for replica in tablet.replicas: if replica[0] == self.host.host_id: diff --git a/cassandra/tablets.py b/cassandra/tablets.py index 216d802061..b386d1a372 100644 --- a/cassandra/tablets.py +++ b/cassandra/tablets.py @@ -76,6 +76,33 @@ def from_row(first_token, last_token, replicas, tablet_version=None): return tablet return None + @property + def leader(self) -> Optional[UUID]: + """ + The ``host_id`` of this tablet's Raft leader, or ``None`` if there is + none to report. + + A strongly-consistent tablet has one distinguished replica, the leader, + that coordinates its writes and its linearizable reads. The server does + not name it in a separate field: ``TABLETS_ROUTING_V2`` orders the + replica set so that the leader comes first, which is why this is simply + ``replicas[0]``. + + That ordering only carries meaning for a tablet of a strongly-consistent + keyspace that was learned over V2. An eventually-consistent tablet has no + leader at all, and a tablet learned over ``TABLETS_ROUTING_V1`` -- which + reports no ``tablet_version``, so ``tablet_version`` is ``None`` -- has no + leader ordering either. Callers must establish both of those before + treating the result as a leader; this property only answers "which + replica is first, if any". + + Returns ``None`` for a tablet with no replicas rather than raising, so + callers do not have to guard the lookup themselves. + """ + if not self.replicas: + return None + return self.replicas[0][0] + def replica_contains_host_id(self, uuid: UUID) -> bool: for replica in self.replicas: if replica[0] == uuid: diff --git a/tests/unit/test_policies.py b/tests/unit/test_policies.py index 63a3c3d12d..35c1a96f87 100644 --- a/tests/unit/test_policies.py +++ b/tests/unit/test_policies.py @@ -26,7 +26,7 @@ from cassandra import ConsistencyLevel from cassandra.cluster import Cluster, ControlConnection -from cassandra.metadata import Metadata +from cassandra.metadata import Metadata, _ConsistencyMode from cassandra.policies import (RackAwareRoundRobinPolicy, RoundRobinPolicy, WhiteListRoundRobinPolicy, DCAwareRoundRobinPolicy, TokenAwarePolicy, SimpleConvictionPolicy, HostDistance, ExponentialReconnectionPolicy, @@ -943,6 +943,427 @@ def _assert_shuffle(self, patched_shuffle, cluster, keyspace, routing_key): child_policy.make_query_plan.assert_called_once_with(keyspace, query) assert patched_shuffle.call_count == 1 + def test_leader_aware_routing_with_tablet_version(self): + """ + For a strongly-consistent keyspace, the leader (first replica in the + tablet's replica list) must be yielded first in the query plan, even + when it is not the closest replica. + """ + hosts = [Host(DefaultEndPoint(str(i)), SimpleConvictionPolicy, host_id=uuid.uuid4()) for i in range(4)] + for i, host in enumerate(hosts): + host.set_up() + host.set_location_info("dc1", f"rack{i + 1}") + + # The leader is hosts[2] (first in tablet.replicas). + leader = hosts[2] + other_replica = hosts[3] + tablet = Tablet( + first_token=-100, last_token=100, + replicas=[(leader.host_id, 0), (other_replica.host_id, 1)], + tablet_version=0xDEADBEEF12345678 + ) + + cluster = Mock(spec=Cluster) + cluster.metadata = Mock(spec=Metadata) + cluster.metadata._tablets = Mock(spec=Tablets) + cluster.metadata._tablets.get_tablet_for_key.return_value = tablet + cluster.metadata.get_replicas.return_value = [leader, other_replica] + cluster.metadata.keyspaces = {'ks': Mock(_consistency_mode=_ConsistencyMode.GLOBAL)} + + child_policy = Mock() + # Put the leader last in the child plan and make it the farther replica + # by distance (LOCAL vs LOCAL_RACK). Without leader-first routing, + # other_replica would be yielded before the leader. + child_policy.make_query_plan.return_value = [hosts[0], hosts[1], other_replica, leader] + distances = { + leader: HostDistance.LOCAL, + other_replica: HostDistance.LOCAL_RACK, + hosts[0]: HostDistance.LOCAL, + hosts[1]: HostDistance.LOCAL, + } + child_policy.distance.side_effect = lambda host: distances.get(host, HostDistance.LOCAL) + + # shuffle_replicas=False keeps replica ordering deterministic so the only + # thing that can pull the leader to the front is the leader-first logic. + policy = TokenAwarePolicy(child_policy, shuffle_replicas=False) + policy.populate(cluster, hosts) + + query = Statement(routing_key=b'\x00\x00\x00\x01', keyspace='ks', table='tbl') + # A non-weak consistency level requires the leader; ONE/LOCAL_ONE would + # instead spread the request across replicas (covered separately). + query.consistency_level = ConsistencyLevel.LOCAL_QUORUM + qplan = list(policy.make_query_plan(None, query)) + + # Leader must be first, even though other_replica is closer (LOCAL_RACK + # vs LOCAL) and the leader is last in the child plan. + self.assertEqual(qplan[0], leader) + # The closer replica follows, and the leader appears exactly once. + self.assertEqual(qplan[1], other_replica) + self.assertEqual(qplan.count(leader), 1) + + def test_leader_fallback_when_leader_is_down(self): + """ + When the leader host is down, the driver should fall back to other + replicas without crashing. The leader should NOT appear in the plan. + """ + hosts = [Host(DefaultEndPoint(str(i)), SimpleConvictionPolicy, host_id=uuid.uuid4()) for i in range(4)] + for i, host in enumerate(hosts): + host.set_up() + host.set_location_info("dc1", f"rack{i + 1}") + + leader = hosts[2] + other_replica = hosts[3] + leader.set_down() # Simulate leader being unreachable. + + tablet = Tablet( + first_token=-100, last_token=100, + replicas=[(leader.host_id, 0), (other_replica.host_id, 1)], + tablet_version=0xCAFEBABE00000001 + ) + + cluster = Mock(spec=Cluster) + cluster.metadata = Mock(spec=Metadata) + cluster.metadata._tablets = Mock(spec=Tablets) + cluster.metadata._tablets.get_tablet_for_key.return_value = tablet + cluster.metadata.get_replicas.return_value = [leader, other_replica] + cluster.metadata.keyspaces = {'ks': Mock(_consistency_mode=_ConsistencyMode.GLOBAL)} + + child_policy = Mock() + child_policy.make_query_plan.return_value = hosts + child_policy.distance.return_value = HostDistance.LOCAL + + policy = TokenAwarePolicy(child_policy) + policy.populate(cluster, hosts) + + query = Statement(routing_key=b'\x00\x00\x00\x01', keyspace='ks', table='tbl') + # A non-weak consistency level makes the leader the preferred target. + query.consistency_level = ConsistencyLevel.LOCAL_QUORUM + qplan = list(policy.make_query_plan(None, query)) + + # Leader is down, should not appear in the plan. + self.assertNotIn(leader, qplan) + # Other replica should be first. + self.assertEqual(qplan[0], other_replica) + + def test_no_leader_routing_without_tablet_version(self): + """ + replicas[0] is only leader-ordered for a tablet that came from a + TABLETS_ROUTING_V2 payload. A versionless tablet (tablet_version=None, + e.g. cached from V1 or stale across a consistency flip) has arbitrary + replica order, so leader-first routing must NOT fire even for a + strongly-consistent keyspace. The version gate is the only thing that + should suppress it here. + """ + hosts = [Host(DefaultEndPoint(str(i)), SimpleConvictionPolicy, host_id=uuid.uuid4()) for i in range(4)] + for i, host in enumerate(hosts): + host.set_up() + host.set_location_info("dc1", f"rack{i + 1}") + + first_replica = hosts[2] + second_replica = hosts[3] + # Strongly-consistent keyspace but a versionless (V1-style) tablet. + tablet = Tablet( + first_token=-100, last_token=100, + replicas=[(first_replica.host_id, 0), (second_replica.host_id, 1)], + tablet_version=None + ) + + cluster = Mock(spec=Cluster) + cluster.metadata = Mock(spec=Metadata) + cluster.metadata._tablets = Mock(spec=Tablets) + cluster.metadata._tablets.get_tablet_for_key.return_value = tablet + cluster.metadata.get_replicas.return_value = [first_replica, second_replica] + cluster.metadata.keyspaces = {'ks': Mock(_consistency_mode=_ConsistencyMode.GLOBAL)} + + child_policy = Mock() + # Order the child plan so the second replica comes before replicas[0]; if + # leader-first wrongly triggered, first_replica would be forced to front. + child_policy.make_query_plan.return_value = [second_replica, first_replica, hosts[0], hosts[1]] + child_policy.distance.return_value = HostDistance.LOCAL + + # shuffle_replicas=False keeps replica ordering deterministic so we can + # assert that no leader is forced to the front. + policy = TokenAwarePolicy(child_policy, shuffle_replicas=False) + policy.populate(cluster, hosts) + + query = Statement(routing_key=b'\x00\x00\x00\x01', keyspace='ks', table='tbl') + qplan = list(policy.make_query_plan(None, query)) + + # Leader-first must NOT apply without a tablet_version: ordering follows + # the child plan, so the second replica (not replicas[0]) stays first. + self.assertEqual(qplan[0], second_replica) + self.assertEqual(len(qplan), 4) + + def test_no_leader_routing_for_eventually_consistent_keyspace(self): + """ + A tablet_version is assigned to eventually-consistent tablet tables too + (TABLETS_ROUTING_V2), but the leader concept only exists for + strongly-consistent keyspaces. For an eventually-consistent keyspace the + leader-first optimization must NOT apply even when a tablet_version is + present. + """ + hosts = [Host(DefaultEndPoint(str(i)), SimpleConvictionPolicy, host_id=uuid.uuid4()) for i in range(4)] + for i, host in enumerate(hosts): + host.set_up() + host.set_location_info("dc1", f"rack{i + 1}") + + first_replica = hosts[2] + second_replica = hosts[3] + tablet = Tablet( + first_token=-100, last_token=100, + replicas=[(first_replica.host_id, 0), (second_replica.host_id, 1)], + tablet_version=0xDEADBEEF12345678 + ) + + cluster = Mock(spec=Cluster) + cluster.metadata = Mock(spec=Metadata) + cluster.metadata._tablets = Mock(spec=Tablets) + cluster.metadata._tablets.get_tablet_for_key.return_value = tablet + cluster.metadata.get_replicas.return_value = [first_replica, second_replica] + cluster.metadata.keyspaces = {'ks': Mock(_consistency_mode=_ConsistencyMode.EVENTUAL)} + + child_policy = Mock() + # Order the child plan so the second replica comes before the first; if + # leader-first logic wrongly triggered, first_replica would be forced to + # the front instead. + child_policy.make_query_plan.return_value = [second_replica, first_replica, hosts[0], hosts[1]] + child_policy.distance.return_value = HostDistance.LOCAL + + # shuffle_replicas=False keeps replica ordering deterministic so we can + # assert that no leader is forced to the front. + policy = TokenAwarePolicy(child_policy, shuffle_replicas=False) + policy.populate(cluster, hosts) + + query = Statement(routing_key=b'\x00\x00\x00\x01', keyspace='ks', table='tbl') + qplan = list(policy.make_query_plan(None, query)) + + # Leader-first must NOT apply: ordering follows the child plan, so the + # second replica (not replicas[0]) stays first. + self.assertEqual(qplan[0], second_replica) + self.assertEqual(len(qplan), 4) + + def test_no_leader_routing_when_keyspace_metadata_missing(self): + """ + If keyspace metadata is unavailable (e.g. schema refresh disabled), the + policy must safely fall back to no leader-first routing rather than + crashing or guessing. + """ + hosts = [Host(DefaultEndPoint(str(i)), SimpleConvictionPolicy, host_id=uuid.uuid4()) for i in range(4)] + for i, host in enumerate(hosts): + host.set_up() + host.set_location_info("dc1", f"rack{i + 1}") + + first_replica = hosts[2] + second_replica = hosts[3] + tablet = Tablet( + first_token=-100, last_token=100, + replicas=[(first_replica.host_id, 0), (second_replica.host_id, 1)], + tablet_version=0xDEADBEEF12345678 + ) + + cluster = Mock(spec=Cluster) + cluster.metadata = Mock(spec=Metadata) + cluster.metadata._tablets = Mock(spec=Tablets) + cluster.metadata._tablets.get_tablet_for_key.return_value = tablet + cluster.metadata.get_replicas.return_value = [first_replica, second_replica] + cluster.metadata.keyspaces = {} # no metadata for 'ks' + + child_policy = Mock() + child_policy.make_query_plan.return_value = [second_replica, first_replica, hosts[0], hosts[1]] + child_policy.distance.return_value = HostDistance.LOCAL + + # shuffle_replicas=False keeps replica ordering deterministic so we can + # assert that no leader is forced to the front. + policy = TokenAwarePolicy(child_policy, shuffle_replicas=False) + policy.populate(cluster, hosts) + + query = Statement(routing_key=b'\x00\x00\x00\x01', keyspace='ks', table='tbl') + qplan = list(policy.make_query_plan(None, query)) + + self.assertEqual(qplan[0], second_replica) + self.assertEqual(len(qplan), 4) + + def test_leader_skipped_when_child_policy_ignores_it(self): + """ + The leader is yielded first only if the child policy would actually use + it. If a (custom) child policy reports the leader as IGNORED, leader-first + routing must respect that and not front-run an excluded host. The leader + should not appear in the plan at all. + """ + hosts = [Host(DefaultEndPoint(str(i)), SimpleConvictionPolicy, host_id=uuid.uuid4()) for i in range(4)] + for i, host in enumerate(hosts): + host.set_up() + host.set_location_info("dc1", f"rack{i + 1}") + + leader = hosts[2] + other_replica = hosts[3] + tablet = Tablet( + first_token=-100, last_token=100, + replicas=[(leader.host_id, 0), (other_replica.host_id, 1)], + tablet_version=0xDEADBEEF12345678 + ) + + cluster = Mock(spec=Cluster) + cluster.metadata = Mock(spec=Metadata) + cluster.metadata._tablets = Mock(spec=Tablets) + cluster.metadata._tablets.get_tablet_for_key.return_value = tablet + cluster.metadata.get_replicas.return_value = [leader, other_replica] + cluster.metadata.keyspaces = {'ks': Mock(_consistency_mode=_ConsistencyMode.GLOBAL)} + + child_policy = Mock() + # The child policy yields the leader but reports it as IGNORED, i.e. it + # would never actually route to it. + child_policy.make_query_plan.return_value = [leader, other_replica, hosts[0], hosts[1]] + distances = { + leader: HostDistance.IGNORED, + other_replica: HostDistance.LOCAL, + hosts[0]: HostDistance.LOCAL, + hosts[1]: HostDistance.LOCAL, + } + child_policy.distance.side_effect = lambda host: distances.get(host, HostDistance.LOCAL) + + # shuffle_replicas=False keeps replica ordering deterministic. + policy = TokenAwarePolicy(child_policy, shuffle_replicas=False) + policy.populate(cluster, hosts) + + query = Statement(routing_key=b'\x00\x00\x00\x01', keyspace='ks', table='tbl') + # A non-weak consistency level keeps the leader eligible; the child + # policy reporting it as IGNORED is what must exclude it here. + query.consistency_level = ConsistencyLevel.LOCAL_QUORUM + qplan = list(policy.make_query_plan(None, query)) + + # The IGNORED leader must not be front-run, nor appear at all. + self.assertNotIn(leader, qplan) + self.assertEqual(qplan[0], other_replica) + + def _make_leader_routing_setup(self, *, consistency_mode=_ConsistencyMode.GLOBAL, + tablet_version=0xDEADBEEF12345678, + default_consistency_level=None, + prefer_tablet_leader=True): + """ + Build a TokenAwarePolicy over a strongly-consistent tablet keyspace in + which the leader (hosts[2]) is the *farther* replica (LOCAL) while + other_replica (hosts[3]) is closer (LOCAL_RACK). With shuffling off, the + only thing that can pull the leader to the front of the plan is the + leader-first hint, so a test can infer whether that hint fired purely + from the resulting order. ``default_consistency_level`` seeds the default + execution profile so tests can exercise the effective-consistency + fallback used when a statement leaves consistency_level unset. + Returns (policy, leader, other_replica). + """ + hosts = [Host(DefaultEndPoint(str(i)), SimpleConvictionPolicy, host_id=uuid.uuid4()) for i in range(4)] + for i, host in enumerate(hosts): + host.set_up() + host.set_location_info("dc1", f"rack{i + 1}") + + leader = hosts[2] + other_replica = hosts[3] + tablet = Tablet( + first_token=-100, last_token=100, + replicas=[(leader.host_id, 0), (other_replica.host_id, 1)], + tablet_version=tablet_version, + ) + + cluster = Mock(spec=Cluster) + cluster.metadata = Mock(spec=Metadata) + cluster.metadata._tablets = Mock(spec=Tablets) + cluster.metadata._tablets.get_tablet_for_key.return_value = tablet + cluster.metadata.get_replicas.return_value = [leader, other_replica] + cluster.metadata.keyspaces = {'ks': Mock(_consistency_mode=consistency_mode)} + cluster.profile_manager.default.consistency_level = default_consistency_level + + child_policy = Mock() + # Leader is last in the child plan and the farther replica (LOCAL vs + # LOCAL_RACK); without leader-first, other_replica is yielded first. + child_policy.make_query_plan.return_value = [hosts[0], hosts[1], other_replica, leader] + distances = { + leader: HostDistance.LOCAL, + other_replica: HostDistance.LOCAL_RACK, + hosts[0]: HostDistance.LOCAL, + hosts[1]: HostDistance.LOCAL, + } + child_policy.distance.side_effect = lambda host: distances.get(host, HostDistance.LOCAL) + + policy = TokenAwarePolicy(child_policy, shuffle_replicas=False, + _prefer_tablet_leader=prefer_tablet_leader) + policy.populate(cluster, hosts) + return policy, leader, other_replica + + def test_no_leader_routing_for_read_with_consistency_level_one_or_local_one(self): + """ + A request at consistency level ONE or LOCAL_ONE is satisfied by any + single replica, so even on a strongly-consistent keyspace the leader + must NOT be forced to the front -- doing so would only create a leader + hotspot. Ordering follows the normal token-aware plan, so the closer + replica stays first and the leader is not front-run. + """ + for cl in (ConsistencyLevel.ONE, ConsistencyLevel.LOCAL_ONE): + policy, leader, other_replica = self._make_leader_routing_setup() + query = Statement(routing_key=b'\x00\x00\x00\x01', keyspace='ks', table='tbl') + query.consistency_level = cl + qplan = list(policy.make_query_plan(None, query)) + + self.assertEqual(qplan[0], other_replica, + "leader must not be front-run for consistency level %s" % cl) + # The leader is still a valid replica -- it just isn't preferred. + self.assertEqual(qplan.count(leader), 1) + + def test_leader_routing_only_for_global_consistency_mode(self): + """ + Only a keyspace whose consistency mode is GLOBAL has a tablet leader. + ScyllaDB does not implement 'local' consistency yet, so LOCAL must be + treated exactly like EVENTUAL and get plain token-aware ordering; this + test has to be revisited once 'local' consistency exists. + """ + for mode in (_ConsistencyMode.LOCAL, _ConsistencyMode.EVENTUAL): + policy, leader, other_replica = self._make_leader_routing_setup(consistency_mode=mode) + query = Statement(routing_key=b'\x00\x00\x00\x01', keyspace='ks', table='tbl') + # A leader-requiring consistency level, so the mode is the only + # thing that can suppress the leader-first hint. + query.consistency_level = ConsistencyLevel.LOCAL_QUORUM + qplan = list(policy.make_query_plan(None, query)) + + self.assertEqual(qplan[0], other_replica, + "leader must not be front-run for consistency mode %s" % mode) + self.assertEqual(qplan.count(leader), 1) + + def test_leader_routing_for_global_consistency_mode(self): + """ + The GLOBAL counterpart of the test above: with the same setup and the + same consistency level, the leader is pulled to the front of the plan + even though it is the farther replica. + """ + policy, leader, other_replica = self._make_leader_routing_setup( + consistency_mode=_ConsistencyMode.GLOBAL) + query = Statement(routing_key=b'\x00\x00\x00\x01', keyspace='ks', table='tbl') + query.consistency_level = ConsistencyLevel.LOCAL_QUORUM + qplan = list(policy.make_query_plan(None, query)) + + self.assertEqual(qplan[0], leader) + self.assertEqual(qplan.count(leader), 1) + + def test_leader_routing_is_on_by_default(self): + # Leader-aware routing is not opt-in: a user who never heard of the + # option still gets the leader-first plan for a strongly-consistent + # table, which is what makes the feature useful by default. + assert TokenAwarePolicy(RoundRobinPolicy())._prefer_tablet_leader is True + + def test_leader_routing_can_be_disabled(self): + """ + With the option off, a strongly-consistent table gets plain token-aware + ordering: the closer replica comes first and the leader is not front-run, + even at a consistency level that the leader would otherwise coordinate. + """ + policy, leader, other_replica = self._make_leader_routing_setup( + prefer_tablet_leader=False) + query = Statement(routing_key=b'\x00\x00\x00\x01', keyspace='ks', table='tbl') + query.consistency_level = ConsistencyLevel.LOCAL_QUORUM + qplan = list(policy.make_query_plan(None, query)) + + self.assertEqual(qplan[0], other_replica) + # The leader is still a replica of the tablet, so it stays in the plan -- + # it just no longer jumps the queue. + self.assertEqual(qplan.count(leader), 1) @patch('cassandra.policies.shuffle') def test_no_shuffle_for_serial_consistency(self, patched_shuffle): diff --git a/tests/unit/test_tablets.py b/tests/unit/test_tablets.py index f77d163eb8..656ae42da7 100644 --- a/tests/unit/test_tablets.py +++ b/tests/unit/test_tablets.py @@ -1,5 +1,6 @@ import unittest from io import BytesIO +from uuid import uuid4 from cassandra import ConsistencyLevel, ProtocolVersion from cassandra.protocol import ExecuteMessage @@ -130,6 +131,32 @@ def __init__(self, v): self.assertIsNone(tablets.get_tablet_for_key("ks", "tb", Token(50))) +class TabletLeaderTest(unittest.TestCase): + """Tests for Tablet.leader, the leader-first replica ordering V2 provides.""" + + def test_leader_is_the_first_replica(self): + leader = uuid4() + follower = uuid4() + tablet = Tablet(first_token=-100, last_token=100, + replicas=[(leader, 3), (follower, 7)], tablet_version=1) + assert tablet.leader == leader + + def test_leader_is_none_without_replicas(self): + # An accessor that raised here would push the guard onto every caller; + # the load balancing policy relies on getting None instead. + assert Tablet(first_token=-100, last_token=100, replicas=[]).leader is None + assert Tablet(first_token=-100, last_token=100, replicas=None).leader is None + + def test_leader_is_reported_regardless_of_version(self): + # Tablet.leader answers "which replica is first", nothing more: a + # versionless (V1-sourced) tablet has no meaningful leader, and deciding + # that is the caller's job, not this property's. + leader = uuid4() + tablet = Tablet(first_token=-100, last_token=100, replicas=[(leader, 0)]) + assert tablet.tablet_version is None + assert tablet.leader == leader + + class TabletVersionBlockTest(unittest.TestCase): """Tests for tablet_version_block encoding used by TABLETS_ROUTING_V2.""" From 0d279dbc4b84197c072583544d52dc5de0d4532e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20M=C4=99drek?= Date: Wed, 8 Jul 2026 16:32:10 +0200 Subject: [PATCH 130/200] Add integration tests for leader-aware routing Extend the TABLETS_ROUTING_V2 integration suite with a strongly-consistent (consistency='global', Raft-backed) keyspace and cover, against a live ScyllaDB: that the driver reads each keyspace's _consistency_mode from system_schema.scylla_keyspaces (statically, and as keyspaces are created and dropped), and that TokenAwarePolicy sends a leader-requiring request for such a table to the Raft leader (replicas[0]). --- .../standard/test_tablets_routing_v2.py | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/tests/integration/standard/test_tablets_routing_v2.py b/tests/integration/standard/test_tablets_routing_v2.py index 22a53ed79d..9edcdcd64e 100644 --- a/tests/integration/standard/test_tablets_routing_v2.py +++ b/tests/integration/standard/test_tablets_routing_v2.py @@ -20,6 +20,7 @@ import cassandra.cqltypes as types from cassandra import ConsistencyLevel from cassandra.cluster import Cluster, ExecutionProfile, EXEC_PROFILE_DEFAULT +from cassandra.metadata import _ConsistencyMode from cassandra.policies import ConstantReconnectionPolicy, RoundRobinPolicy, TokenAwarePolicy from cassandra.protocol import ExecuteMessage from cassandra.protocol_features import ( @@ -120,6 +121,24 @@ def _create_schema(cls, session): for i in range(50): session.execute(prepared.bind((i, i))) + session.execute("DROP KEYSPACE IF EXISTS test_v2_sc") + session.execute( + """ + CREATE KEYSPACE test_v2_sc + WITH replication = {'class': 'NetworkTopologyStrategy', 'replication_factor': 2} + AND tablets = {'initial': 8} + AND consistency = 'global' + """) + session.execute("CREATE TABLE test_v2_sc.t (pk int PRIMARY KEY, v int)") + prepared_sc = session.prepare("INSERT INTO test_v2_sc.t (pk, v) VALUES (?, ?)") + # Writes to a strongly-consistent (Raft) table are rejected unless they + # use QUORUM/LOCAL_QUORUM. The session default is LOCAL_ONE, so request + # LOCAL_QUORUM for these inserts; it propagates to every statement bound + # from this prepared one. + prepared_sc.consistency_level = ConsistencyLevel.LOCAL_QUORUM + for i in range(50): + session.execute(prepared_sc.bind((i, i))) + # -- helpers ---------------------------------------------------------------- @classmethod @@ -405,3 +424,125 @@ def test_v2_takes_precedence_over_v1_no_v1_payload_on_wrong_shard(self): "V2 must take precedence (select_statement.cc)") # The correct V2 block also means no V2 payload. assert 'tablets-routing-v2' not in payload + + # -- strongly-consistent (leader-aware) routing ----------------------------- + + def test_strongly_consistent_keyspace_metadata(self): + """ + The driver must learn from system_schema.scylla_keyspaces which keyspaces + are strongly consistent: test_v2_sc (consistency='global') reports GLOBAL, + test_v2 (no consistency clause) reports EVENTUAL. A GLOBAL mode is the + precondition for leader-aware routing in + TokenAwarePolicy.make_query_plan. + """ + self.session.cluster.refresh_schema_metadata() + keyspaces = self.session.cluster.metadata.keyspaces + assert keyspaces['test_v2_sc']._consistency_mode is _ConsistencyMode.GLOBAL + assert keyspaces['test_v2']._consistency_mode is _ConsistencyMode.EVENTUAL + + def test_consistency_mode_tracks_dynamic_keyspace_changes(self): + """ + The driver reads system_schema.scylla_keyspaces on every schema refresh + and sets KeyspaceMetadata._consistency_mode for each keyspace. + + The mode is not a one-off computed at connect time -- it has to track the + live schema. Because the driver refreshes its metadata synchronously in + response to a DDL it executes (ResponseFuture handles + RESULT_KIND_SCHEMA_CHANGE by refreshing before returning), a keyspace + created or dropped *after* connecting is immediately reflected in + cluster.metadata.keyspaces, with no sleep or manual refresh required. A + keyspace created by some *other* client is picked up through the very same + refresh path, driven by the control connection's schema-change events + (subject to the schema refresh window); this test drives the changes + through the connected session so the assertions stay deterministic. + """ + metadata = self.session.cluster.metadata + ec_ks = "dyn_ec_ks" # eventually consistent (no consistency clause) + sc_ks = "dyn_sc_ks" # strongly consistent (Raft, consistency='global') + + # Start from a known-clean slate so the test is repeatable. + self.session.execute("DROP KEYSPACE IF EXISTS {0}".format(ec_ks)) + self.session.execute("DROP KEYSPACE IF EXISTS {0}".format(sc_ks)) + try: + assert ec_ks not in metadata.keyspaces + assert sc_ks not in metadata.keyspaces + + # Create an eventually-consistent keyspace after connecting: it shows + # up in the map as EVENTUAL. This exercises the + # "absent from scylla_keyspaces -> eventual" path. + self.session.execute( + "CREATE KEYSPACE {0} WITH replication = " + "{{'class': 'NetworkTopologyStrategy', 'replication_factor': 1}} " + "AND tablets = {{'initial': 1}}".format(ec_ks)) + assert ec_ks in metadata.keyspaces + assert metadata.keyspaces[ec_ks]._consistency_mode is _ConsistencyMode.EVENTUAL + + # Create a strongly-consistent keyspace: same map, mode now GLOBAL. + # (RF is irrelevant here -- the mode only tracks the consistency option.) + self.session.execute( + "CREATE KEYSPACE {0} WITH replication = " + "{{'class': 'NetworkTopologyStrategy', 'replication_factor': 1}} " + "AND tablets = {{'initial': 1}} AND consistency = 'global'".format(sc_ks)) + assert sc_ks in metadata.keyspaces + assert metadata.keyspaces[sc_ks]._consistency_mode is _ConsistencyMode.GLOBAL + # The previously-created keyspace keeps its EVENTUAL mode. + assert metadata.keyspaces[ec_ks]._consistency_mode is _ConsistencyMode.EVENTUAL + + # A full refresh (the bulk get_all_keyspaces path, as opposed to the + # single-keyspace get_keyspace path the DDL above exercised) agrees. + self.session.cluster.refresh_schema_metadata() + assert metadata.keyspaces[ec_ks]._consistency_mode is _ConsistencyMode.EVENTUAL + assert metadata.keyspaces[sc_ks]._consistency_mode is _ConsistencyMode.GLOBAL + + # Dropping a keyspace removes it from the map and leaves the other + # keyspace's mode untouched. + self.session.execute("DROP KEYSPACE {0}".format(sc_ks)) + assert sc_ks not in metadata.keyspaces + assert metadata.keyspaces[ec_ks]._consistency_mode is _ConsistencyMode.EVENTUAL + + self.session.execute("DROP KEYSPACE {0}".format(ec_ks)) + assert ec_ks not in metadata.keyspaces + finally: + self.session.execute("DROP KEYSPACE IF EXISTS {0}".format(ec_ks)) + self.session.execute("DROP KEYSPACE IF EXISTS {0}".format(sc_ks)) + + def test_leader_aware_routing_targets_the_raft_leader(self): + """ + For a strongly-consistent table, the server orders the replica list with + the Raft leader first. Once that payload is cached, a TokenAwarePolicy + must route every leader-requiring request (here a LOCAL_QUORUM read) for + the tablet to replicas[0] (the leader), saving the extra + coordinator->leader hop. This is the strongly-consistent counterpart to + the eventually consistent test_v2 tests above, which never assert *which* + replica is hit. + + A read at ONE/LOCAL_ONE is intentionally *not* pinned to the leader (any + single replica satisfies it); that carve-out is covered by the policy + unit tests. + """ + select = self.session.prepare("SELECT v FROM test_v2_sc.t WHERE pk = ?") + bound = select.bind([2]) + # Leader-first routing only applies to requests that actually need the + # leader, so request LOCAL_QUORUM (a strong read). The session default is + # LOCAL_ONE, which the policy would deliberately spread across replicas. + bound.consistency_level = ConsistencyLevel.LOCAL_QUORUM + + tablet = self._ensure_cached(bound) + assert tablet.replicas, "strongly-consistent tablet has no replicas" + leader_host_id = tablet.replicas[0][0] + + # Leader-first routing only triggers when the keyspace is known to be + # strongly consistent, i.e. when its consistency mode is GLOBAL. + ks_meta = self.session.cluster.metadata.keyspaces['test_v2_sc'] + assert ks_meta._consistency_mode is _ConsistencyMode.GLOBAL + + # With an up-to-date cache the block always matches, so the server returns + # no further payload and replicas[0] stays the leader; every request must + # therefore be coordinated by that leader. + for _ in range(10): + result = self.session.execute(bound) + coordinator = result.response_future.coordinator_host + assert coordinator is not None and coordinator.host_id == leader_host_id, ( + "request coordinated by {} but the Raft leader is replicas[0]={}; " + "leader-aware routing did not target the leader".format( + getattr(coordinator, 'host_id', None), leader_host_id)) From e5f5d626bd5386a1d5d07be6fae3aaad3d0b979b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20M=C4=99drek?= Date: Wed, 8 Jul 2026 16:32:32 +0200 Subject: [PATCH 131/200] Document leader-aware routing Extend the Scylla-specific guide's TABLETS_ROUTING_V2 section, which the previous docs commit introduced for tablet-version tracking, to cover leader-aware routing: strongly-consistent (Raft-backed) tablet tables have a leader that the driver targets directly to save the coordinator->leader hop, the behaviour is bounded by the load-balancing policy, and eventually-consistent tables keep their usual token-aware ordering. Include a table of how ScyllaDB serves each operation on such a table, so the routing distinction has a visible reason: ONE and LOCAL_ONE reads are non-linearizable and take no Raft read barrier, so they keep normal token-aware ordering, while QUORUM and LOCAL_QUORUM reads and writes go through the leader and are routed to it. Spell out how far the preference reaches, since "bounded by the policy" alone is ambiguous: among the hosts the wrapped policy is willing to use the leader outranks distance, but a leader that policy ignores is never contacted, so a datacenter-aware policy with no remote hosts keeps the request local and lets the server forward it. Document the private _prefer_tablet_leader option that turns the preference off, and note that it is unstable while strong consistency is experimental. --- docs/scylla-specific.rst | 65 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 6 deletions(-) diff --git a/docs/scylla-specific.rst b/docs/scylla-specific.rst index 80071d5102..e7cb986b6d 100644 --- a/docs/scylla-specific.rst +++ b/docs/scylla-specific.rst @@ -158,17 +158,17 @@ Details on the sending tablet information to the drivers https://github.com/scylladb/scylladb/blob/master/docs/dev/protocol-extensions.md#sending-tablet-info-to-the-drivers -Tablet version tracking ------------------------ +Tablet version tracking and leader-aware routing +------------------------------------------------ When the cluster offers it, the driver negotiates ``TABLETS_ROUTING_V2`` in preference to V1. The negotiation happens per connection, so V2 and V1 connections can coexist in the same cluster; each connection uses whichever -extension its node offers. V2 adds tablet version tracking on top of V1, +extension its node offers. V2 adds two capabilities on top of V1, both invisible to application code. -Every tablet now carries a ``tablet_version`` that -changes whenever its replica set is reconfigured. The driver caches the version +**Tablet version tracking.** Every tablet now carries a ``tablet_version`` that +changes whenever its replica set or leader changes. The driver caches the version it last saw for each tablet and, on every prepared-statement execution over a V2 connection, appends a single ``tablet_version_block`` byte derived from it. The server returns updated routing information in the ``custom_payload`` only when @@ -176,8 +176,61 @@ that byte shows the driver's cached view is stale, instead of attaching it to every response. This keeps the cached routing information fresh while avoiding the per-response overhead that V1 incurs. +**Leader-aware routing for strongly-consistent tables.** Tables in a +strongly-consistent keyspace -- one created with a ``consistency`` option and +backed by Raft -- have a tablet leader that coordinates operations. For those +tables, the driver sends each request directly to the leader, saving the extra +hop the coordinator would otherwise take to forward it. Reads with consistency +level ``ONE`` or ``LOCAL_ONE`` are an exception to this and retain normal +token-aware replica ordering. Eventually-consistent tables are completely +unaffected and keep their usual token-aware (optionally shuffled) replica +ordering. + +The distinction follows from how ScyllaDB serves each operation on a +strongly-consistent table: + +.. list-table:: + :header-rows: 1 + :widths: 15 25 60 + + * - Operation + - Consistency level + - How it is served + * - Read + - ``ONE``, ``LOCAL_ONE`` + - Non-linearizable: served by any replica, without taking a Raft read + barrier. There is nothing to gain from preferring the leader, so the + driver keeps normal token-aware ordering. + * - Read + - ``QUORUM``, ``LOCAL_QUORUM`` + - Linearizable: goes through the Raft leader, so the driver routes it to + the leader directly. + * - Write + - ``QUORUM``, ``LOCAL_QUORUM`` + - Committed through Raft by the leader, so the driver routes it to the + leader directly. + * - Write + - anything else + - Rejected by the server: strongly-consistent tables accept only + ``QUORUM`` and ``LOCAL_QUORUM`` writes. + +Leader-aware routing is best-effort and bounded by the load-balancing policy: +the leader is only targeted directly if the wrapped policy would consider it in +the first place. For example, a ``DCAwareRoundRobinPolicy`` configured with no +remote hosts will not send cross-datacenter traffic to a leader in another +datacenter; the request goes to a local replica and the server forwards it to +the leader, exactly as it would without V2. + +Within the hosts the wrapped policy allows, though, the leader does outrank +distance: it is yielded ahead of a nearer replica, since every write and every +linearizable read has to be coordinated by it anyway and a globally-consistent +table gains no consistency from staying in one datacenter. + No configuration is required: as with V1, a ``TokenAwarePolicy`` is all that is -needed. +needed. Leader preference can be turned off per policy instance with its private +``_prefer_tablet_leader`` option, which leaves strongly-consistent tables with +plain token-aware ordering. The option is private and unstable while strong +consistency is experimental. .. note:: From 01f61bf17622d5ad877a3e9b499c783d12d66094 Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Sun, 16 Aug 2026 10:05:56 +0300 Subject: [PATCH 132/200] tests: fix flaky NTS token performance test The wall-clock timing assertion (RF=1500 vs RF=3 must differ by <1s) failed intermittently on loaded CI runners when scheduling noise delayed the first measurement. Convert the test to assert the actual property PYTHON-379 targeted: with more replicas than nodes, the replica map must only contain the nodes that exist and match the normal-RF map. Signed-off-by: Yaniv Michael Kaul --- tests/unit/test_metadata.py | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/tests/unit/test_metadata.py b/tests/unit/test_metadata.py index a058f73c61..a9cee59b49 100644 --- a/tests/unit/test_metadata.py +++ b/tests/unit/test_metadata.py @@ -17,7 +17,6 @@ import logging from unittest.mock import Mock import os -import timeit import uuid import cassandra @@ -226,12 +225,13 @@ def test_nts_make_token_replica_map(self): def test_nts_token_performance(self): """ - Tests to ensure that when rf exceeds the number of nodes available, that we dont' - needlessly iterate trying to construct tokens for nodes that don't exist. + When rf exceeds the number of nodes available, the replica map must + only contain the nodes that exist (one replica set per token), not + iterate to build replicas for nodes that don't exist. @since 3.7 @jira_ticket PYTHON-379 - @expected_result timing with 1500 rf should be same/similar to 3rf if we have 3 nodes + @expected_result 1500 rf with 3 nodes produces the same replica map as 3 rf @test_category metadata """ @@ -251,17 +251,21 @@ def test_nts_token_performance(self): ring.append(md5_token) current_token += 1000 - nts = NetworkTopologyStrategy({'dc1': 3}) - start_time = timeit.default_timer() - nts.make_token_replica_map(token_to_host_owner, ring) - elapsed_base = timeit.default_timer() - start_time - - nts = NetworkTopologyStrategy({'dc1': 1500}) - start_time = timeit.default_timer() - nts.make_token_replica_map(token_to_host_owner, ring) - elapsed_bad = timeit.default_timer() - start_time - difference = elapsed_bad - elapsed_base - assert difference < 1 and difference > -1 + expected_replicas = set(token_to_host_owner.values()) + + replica_map_rf3 = NetworkTopologyStrategy({'dc1': 3}).make_token_replica_map( + token_to_host_owner, ring) + replica_map_rf1500 = NetworkTopologyStrategy({'dc1': 1500}).make_token_replica_map( + token_to_host_owner, ring) + + for replica_map in (replica_map_rf3, replica_map_rf1500): + assert set(replica_map) == set(ring) + assert all( + len(replicas) == dc1hostnum + and set(replicas) == expected_replicas + for replicas in replica_map.values() + ) + assert replica_map_rf1500 == replica_map_rf3 def test_nts_make_token_replica_map_multi_rack(self): token_to_host_owner = {} From 7ad62ec2d826f8df877cf24e78494a83be6e6a46 Mon Sep 17 00:00:00 2001 From: Yaniv Kaul Date: Tue, 11 Aug 2026 12:10:08 +0300 Subject: [PATCH 133/200] Invalidate tablets when table is dropped via schema event Signed-off-by: Yaniv Michael Kaul --- cassandra/metadata.py | 1 + tests/integration/standard/test_tablets.py | 37 ++++++++++++++++++++-- tests/unit/test_metadata.py | 26 +++++++++++++++ 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/cassandra/metadata.py b/cassandra/metadata.py index 5669e6a80e..326d046ecd 100644 --- a/cassandra/metadata.py +++ b/cassandra/metadata.py @@ -227,6 +227,7 @@ def _drop_table(self, keyspace, table): except KeyError: # can happen if keyspace disappears while processing async event pass + self._table_removed(keyspace, table) def _update_type(self, type_meta): try: diff --git a/tests/integration/standard/test_tablets.py b/tests/integration/standard/test_tablets.py index 45e8a807ea..0491b15f3f 100644 --- a/tests/integration/standard/test_tablets.py +++ b/tests/integration/standard/test_tablets.py @@ -218,6 +218,37 @@ def drop_ks(_): self.run_tablets_invalidation_test(drop_ks) + def test_tablets_invalidation_drop_table(self): + """Dropping a table invalidates its tablet metadata via the schema change event.""" + keyspace, table = "test_drop_table", "table1" + + # Own keyspace/table so this test doesn't disturb state shared with other tests + self.session.execute(f"DROP KEYSPACE IF EXISTS {keyspace}") + self.session.execute( + f""" + CREATE KEYSPACE {keyspace} + WITH replication = {{ + 'class': 'NetworkTopologyStrategy', + 'replication_factor': 2 + }} AND tablets = {{ + 'initial': 8 + }} + """) + self.session.execute(f"CREATE TABLE {keyspace}.{table} (pk int, ck int, v int, PRIMARY KEY (pk, ck))") + + prepared = self.session.prepare(f"INSERT INTO {keyspace}.{table} (pk, ck, v) VALUES (?, ?, ?)") + for i in range(50): + self.session.execute(prepared.bind((i, i % 5, i % 2))) + + def drop_table(_): + # Drop table to trigger tablets invalidation + self.session.execute(f"DROP TABLE {keyspace}.{table}") + + try: + self.run_tablets_invalidation_test(drop_table, keyspace=keyspace, table=table) + finally: + self.session.execute(f"DROP KEYSPACE IF EXISTS {keyspace}") + @pytest.mark.last def test_tablets_invalidation_decommission_non_cc_node(self): def decommission_non_cc_node(rec): @@ -245,12 +276,12 @@ def decommission_non_cc_node(rec): self.run_tablets_invalidation_test(decommission_non_cc_node) - def run_tablets_invalidation_test(self, invalidate): + def run_tablets_invalidation_test(self, invalidate, keyspace="test1", table="table1"): # Make sure driver holds tablet info # By landing query to the host that is not in replica set bound = self.session.prepare( - """ - SELECT pk, ck, v FROM test1.table1 WHERE pk = ? + f""" + SELECT pk, ck, v FROM {keyspace}.{table} WHERE pk = ? """).bind([(2)]) rec = None diff --git a/tests/unit/test_metadata.py b/tests/unit/test_metadata.py index a9cee59b49..2a1fced6cf 100644 --- a/tests/unit/test_metadata.py +++ b/tests/unit/test_metadata.py @@ -447,6 +447,32 @@ def test_bytes_tokens(self): self._get_replicas(BytesToken) +class DropTableMetadataTest(unittest.TestCase): + """Metadata._drop_table should invalidate tablets for the dropped table.""" + + def setUp(self): + """Set up metadata containing a table with a tablet record.""" + self.metadata = Metadata() + keyspace = KeyspaceMetadata("ks", True, "NetworkTopologyStrategy", {"dc1": "1"}) + keyspace.tables["tb"] = TableMetadata("ks", "tb") + self.metadata.keyspaces["ks"] = keyspace + self.metadata._tablets.add_tablet("ks", "tb", Tablet(0, 100, [("host1", 0)])) + + def test_drop_table_invalidates_tablets(self): + """Dropping a known table removes its tablet and table metadata.""" + self.metadata._drop_table("ks", "tb") + + assert self.metadata._tablets.table_has_tablets("ks", "tb") is False + assert "tb" not in self.metadata.keyspaces["ks"].tables + + def test_drop_table_invalidates_tablets_for_unknown_keyspace(self): + """Dropping a table in an unknown keyspace still removes its tablet metadata.""" + self.metadata._tablets.add_tablet("unknown", "tb", Tablet(0, 100, [("host1", 0)])) + self.metadata._drop_table("unknown", "tb") + + assert self.metadata._tablets.table_has_tablets("unknown", "tb") is False + + class Murmur3TokensTest(unittest.TestCase): def test_murmur3_init(self): From 71025a4aaffc0aaf6e3e0a9f9533a7b01727c36a Mon Sep 17 00:00:00 2001 From: brettabamonte Date: Sun, 24 May 2026 15:39:56 -0400 Subject: [PATCH 134/200] CASSPYTHON-13: Remove eventlet, gevent and twisted event loops patch by Brett Abamonte; reviewed by Bret McGuire (cherry picked from commit 8b39688d703c0c84fd849e344f9253f152f1288e) Signed-off-by: Yaniv Michael Kaul --- benchmarks/base.py | 16 - cassandra/connection.py | 5 +- cassandra/datastax/cloud/__init__.py | 45 +-- cassandra/datastax/insights/reporter.py | 6 +- cassandra/io/eventletreactor.py | 194 ----------- cassandra/io/geventreactor.py | 139 -------- cassandra/io/twistedreactor.py | 308 ------------------ docs/api/cassandra/io/eventletreactor.rst | 9 - docs/api/cassandra/io/geventreactor.rst | 9 - docs/api/cassandra/io/twistedreactor.rst | 11 - docs/api/index.rst | 3 - docs/installation.rst | 10 +- docs/pyproject.toml | 2 - docs/security.rst | 40 --- docs/uv.lock | 112 ------- tests/__init__.py | 49 +-- tests/integration/long/test_ipv6.py | 6 - tests/integration/long/test_ssl.py | 121 ++----- tests/integration/standard/test_connection.py | 5 - tests/unit/io/eventlet_utils.py | 42 --- tests/unit/io/gevent_utils.py | 56 ---- tests/unit/io/test_asyncioreactor.py | 6 +- tests/unit/io/test_asyncorereactor.py | 12 +- tests/unit/io/test_eventletreactor.py | 77 ----- tests/unit/io/test_geventreactor.py | 64 ---- tests/unit/io/test_libevreactor.py | 9 +- tests/unit/io/test_libevreactor_shutdown.py | 6 - tests/unit/io/test_twistedreactor.py | 208 ------------ tests/unit/io/utils.py | 12 - 29 files changed, 65 insertions(+), 1517 deletions(-) delete mode 100644 cassandra/io/eventletreactor.py delete mode 100644 cassandra/io/geventreactor.py delete mode 100644 cassandra/io/twistedreactor.py delete mode 100644 docs/api/cassandra/io/eventletreactor.rst delete mode 100644 docs/api/cassandra/io/geventreactor.rst delete mode 100644 docs/api/cassandra/io/twistedreactor.rst delete mode 100644 tests/unit/io/eventlet_utils.py delete mode 100644 tests/unit/io/gevent_utils.py delete mode 100644 tests/unit/io/test_eventletreactor.py delete mode 100644 tests/unit/io/test_geventreactor.py delete mode 100644 tests/unit/io/test_twistedreactor.py diff --git a/benchmarks/base.py b/benchmarks/base.py index 3922eefad5..b890135a6e 100644 --- a/benchmarks/base.py +++ b/benchmarks/base.py @@ -65,15 +65,6 @@ except (ImportError, SyntaxError): pass -have_twisted = False -try: - from cassandra.io.twistedreactor import TwistedConnection - have_twisted = True - supported_reactors.append(TwistedConnection) -except ImportError as exc: - log.exception("Error importing twisted") - pass - KEYSPACE = "testkeyspace" + str(int(time.time())) TABLE = "testtable" @@ -228,8 +219,6 @@ def parse_options(): help='only benchmark with asyncio connections') parser.add_option('--libev-only', action='store_true', dest='libev_only', help='only benchmark with libev connections') - parser.add_option('--twisted-only', action='store_true', dest='twisted_only', - help='only benchmark with Twisted connections') parser.add_option('-m', '--metrics', action='store_true', dest='enable_metrics', help='enable and print metrics for operations') parser.add_option('-l', '--log-level', default='info', @@ -269,11 +258,6 @@ def parse_options(): log.error("libev is not available") sys.exit(1) options.supported_reactors = [LibevConnection] - elif options.twisted_only: - if not have_twisted: - log.error("Twisted is not available") - sys.exit(1) - options.supported_reactors = [TwistedConnection] else: options.supported_reactors = supported_reactors if not have_libev: diff --git a/cassandra/connection.py b/cassandra/connection.py index ac2578a16c..72a8d1b13c 100644 --- a/cassandra/connection.py +++ b/cassandra/connection.py @@ -34,10 +34,7 @@ from cassandra.client_routes import _ClientRoutesHandler from cassandra.protocol_features import ProtocolFeatures -if 'gevent.monkey' in sys.modules: - from gevent.queue import Queue, Empty -else: - from queue import Queue, Empty # noqa +from queue import Queue, Empty # noqa from cassandra import ConsistencyLevel, AuthenticationFailed, OperationTimedOut, ProtocolVersion from cassandra.marshal import int32_pack diff --git a/cassandra/datastax/cloud/__init__.py b/cassandra/datastax/cloud/__init__.py index be79d6db38..42cd1b6752 100644 --- a/cassandra/datastax/cloud/__init__.py +++ b/cassandra/datastax/cloud/__init__.py @@ -75,7 +75,7 @@ def from_dict(cls, d): return c -def get_cloud_config(cloud_config, create_pyopenssl_context=False): +def get_cloud_config(cloud_config): if not _HAS_SSL: raise DriverException("A Python installation with SSL is required to connect to a cloud cluster.") @@ -83,30 +83,36 @@ def get_cloud_config(cloud_config, create_pyopenssl_context=False): raise ValueError("The cloud config doesn't have a secure_connect_bundle specified.") try: - config = read_cloud_config_from_zip(cloud_config, create_pyopenssl_context) - except BadZipFile: - raise ValueError("Unable to open the zip file for the cloud config. Check your secure connect bundle.") + config = read_cloud_config_from_zip(cloud_config) + except BadZipFile as err: + raise ValueError("Unable to open the zip file for the cloud config. Check your secure connect bundle.") from err config = read_metadata_info(config, cloud_config) - if create_pyopenssl_context: - config.ssl_context = config.pyopenssl_context return config -def read_cloud_config_from_zip(cloud_config, create_pyopenssl_context): +def _safe_extractall(zipfile, tmp_dir): + for member in zipfile.namelist(): + target = os.path.realpath(os.path.join(tmp_dir, member)) + if not target.startswith(os.path.realpath(tmp_dir) + os.sep): + raise ValueError("Secure connect bundle contains an unsafe path: %s" % member) + zipfile.extractall(path=tmp_dir) + + +def read_cloud_config_from_zip(cloud_config): secure_bundle = cloud_config['secure_connect_bundle'] use_default_tempdir = cloud_config.get('use_default_tempdir', None) with ZipFile(secure_bundle) as zipfile: base_dir = tempfile.gettempdir() if use_default_tempdir else os.path.dirname(secure_bundle) tmp_dir = tempfile.mkdtemp(dir=base_dir) try: - zipfile.extractall(path=tmp_dir) - return parse_cloud_config(os.path.join(tmp_dir, 'config.json'), cloud_config, create_pyopenssl_context) + _safe_extractall(zipfile, tmp_dir) + return parse_cloud_config(os.path.join(tmp_dir, 'config.json'), cloud_config) finally: shutil.rmtree(tmp_dir) -def parse_cloud_config(path, cloud_config, create_pyopenssl_context): +def parse_cloud_config(path, cloud_config): with open(path, 'r') as stream: data = json.load(stream) @@ -120,11 +126,7 @@ def parse_cloud_config(path, cloud_config, create_pyopenssl_context): ca_cert_location = os.path.join(config_dir, 'ca.crt') cert_location = os.path.join(config_dir, 'cert') key_location = os.path.join(config_dir, 'key') - # Regardless of if we create a pyopenssl context, we still need the builtin one - # to connect to the metadata service config.ssl_context = _ssl_context_from_cert(ca_cert_location, cert_location, key_location) - if create_pyopenssl_context: - config.pyopenssl_context = _pyopenssl_context_from_cert(ca_cert_location, cert_location, key_location) return config @@ -175,18 +177,3 @@ def _ssl_context_from_cert(ca_cert_location, cert_location, key_location): return ssl_context - -def _pyopenssl_context_from_cert(ca_cert_location, cert_location, key_location): - try: - from OpenSSL import SSL - except ImportError as e: - raise ImportError( - "PyOpenSSL must be installed to connect to Astra with the Eventlet or Twisted event loops")\ - .with_traceback(e.__traceback__) - ssl_context = SSL.Context(SSL.TLSv1_METHOD) - ssl_context.set_verify(SSL.VERIFY_PEER, callback=lambda _1, _2, _3, _4, ok: ok) - ssl_context.use_certificate_file(cert_location) - ssl_context.use_privatekey_file(key_location) - ssl_context.load_verify_locations(ca_cert_location) - - return ssl_context \ No newline at end of file diff --git a/cassandra/datastax/insights/reporter.py b/cassandra/datastax/insights/reporter.py index 83205fc458..bd923d6036 100644 --- a/cassandra/datastax/insights/reporter.py +++ b/cassandra/datastax/insights/reporter.py @@ -142,11 +142,7 @@ def _get_startup_data(self): cert_validation = None try: if self._session.cluster.ssl_context: - if isinstance(self._session.cluster.ssl_context, ssl.SSLContext): - cert_validation = self._session.cluster.ssl_context.verify_mode == ssl.CERT_REQUIRED - else: # pyopenssl - from OpenSSL import SSL - cert_validation = self._session.cluster.ssl_context.get_verify_mode() != SSL.VERIFY_NONE + cert_validation = self._session.cluster.ssl_context.verify_mode == ssl.CERT_REQUIRED elif self._session.cluster.ssl_options: cert_validation = self._session.cluster.ssl_options.get('cert_reqs') == ssl.CERT_REQUIRED except Exception as e: diff --git a/cassandra/io/eventletreactor.py b/cassandra/io/eventletreactor.py deleted file mode 100644 index 234a4a574c..0000000000 --- a/cassandra/io/eventletreactor.py +++ /dev/null @@ -1,194 +0,0 @@ -# Copyright 2014 Symantec Corporation -# Copyright DataStax, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# Originally derived from MagnetoDB source: -# https://github.com/stackforge/magnetodb/blob/2015.1.0b1/magnetodb/common/cassandra/io/eventletreactor.py -import eventlet -from eventlet.green import socket -from eventlet.queue import Queue -from greenlet import GreenletExit -import logging -from threading import Event -import time - -from cassandra.connection import Connection, ConnectionShutdown, Timer, TimerManager -try: - from eventlet.green.OpenSSL import SSL - _PYOPENSSL = True -except ImportError as e: - _PYOPENSSL = False - no_pyopenssl_error = e - - -log = logging.getLogger(__name__) - - -def _check_pyopenssl(): - if not _PYOPENSSL: - raise ImportError( - "{}, pyOpenSSL must be installed to enable " - "SSL support with the Eventlet event loop".format(str(no_pyopenssl_error)) - ) - - -class EventletConnection(Connection): - """ - An implementation of :class:`.Connection` that utilizes ``eventlet``. - - This implementation assumes all eventlet monkey patching is active. It is not tested with partial patching. - """ - - _read_watcher = None - _write_watcher = None - - _socket_impl = eventlet.green.socket - _ssl_impl = eventlet.green.ssl - - _timers = None - _timeout_watcher = None - _new_timer = None - - @classmethod - def initialize_reactor(cls): - eventlet.monkey_patch() - if not cls._timers: - cls._timers = TimerManager() - cls._timeout_watcher = eventlet.spawn(cls.service_timeouts) - cls._new_timer = Event() - - @classmethod - def create_timer(cls, timeout, callback): - timer = Timer(timeout, callback) - cls._timers.add_timer(timer) - cls._new_timer.set() - return timer - - @classmethod - def service_timeouts(cls): - """ - cls._timeout_watcher runs in this loop forever. - It is usually waiting for the next timeout on the cls._new_timer Event. - When new timers are added, that event is set so that the watcher can - wake up and possibly set an earlier timeout. - """ - timer_manager = cls._timers - while True: - next_end = timer_manager.service_timeouts() - sleep_time = max(next_end - time.time(), 0) if next_end else 10000 - cls._new_timer.wait(sleep_time) - cls._new_timer.clear() - - def __init__(self, *args, **kwargs): - Connection.__init__(self, *args, **kwargs) - self.uses_legacy_ssl_options = self.ssl_options and not self.ssl_context - self._write_queue = Queue() - - self._connect_socket() - - self._read_watcher = eventlet.spawn(lambda: self.handle_read()) - self._write_watcher = eventlet.spawn(lambda: self.handle_write()) - self._send_options_message() - - def _wrap_socket_from_context(self): - _check_pyopenssl() - self._socket = SSL.Connection(self.ssl_context, self._socket) - self._socket.set_connect_state() - if self.ssl_options and 'server_hostname' in self.ssl_options: - # This is necessary for SNI - self._socket.set_tlsext_host_name(self.ssl_options['server_hostname'].encode('ascii')) - - def _initiate_connection(self, sockaddr): - if self.uses_legacy_ssl_options: - super(EventletConnection, self)._initiate_connection(sockaddr) - else: - self._socket.connect(sockaddr) - if self.ssl_context or self.ssl_options: - self._socket.do_handshake() - - def _match_hostname(self): - if self.uses_legacy_ssl_options: - super(EventletConnection, self)._match_hostname() - else: - cert_name = self._socket.get_peer_certificate().get_subject().commonName - if cert_name != self.endpoint.address: - raise Exception("Hostname verification failed! Certificate name '{}' " - "doesn't endpoint '{}'".format(cert_name, self.endpoint.address)) - - def close(self): - with self.lock: - if self.is_closed: - return - self.is_closed = True - - log.debug("Closing connection (%s) to %s" % (id(self), self.endpoint)) - - cur_gthread = eventlet.getcurrent() - - if self._read_watcher and self._read_watcher != cur_gthread: - self._read_watcher.kill() - if self._write_watcher and self._write_watcher != cur_gthread: - self._write_watcher.kill() - if self._socket: - self._socket.close() - log.debug("Closed socket to %s" % (self.endpoint,)) - - if not self.is_defunct: - msg = "Connection to %s was closed" % self.endpoint - if self.last_error: - msg += ": %s" % (self.last_error,) - self.error_all_requests(ConnectionShutdown(msg)) - # don't leave in-progress operations hanging - self.connected_event.set() - - def handle_close(self): - log.debug("connection closed by server") - self.close() - - def handle_write(self): - while True: - try: - next_msg = self._write_queue.get() - self._socket.sendall(next_msg) - except socket.error as err: - log.debug("Exception during socket send for %s: %s", self, err) - self.defunct(err) - return # Leave the write loop - except GreenletExit: # graceful greenthread exit - return - - def handle_read(self): - while True: - try: - buf = self._socket.recv(self.in_buffer_size) - self._iobuf.write(buf) - except socket.error as err: - log.debug("Exception during socket recv for %s: %s", - self, err) - self.defunct(err) - return # leave the read loop - except GreenletExit: # graceful greenthread exit - return - - if buf and self._iobuf.tell(): - self.process_io_buffer() - else: - log.debug("Connection %s closed by server", self) - self.close() - return - - def push(self, data): - chunk_size = self.out_buffer_size - for i in range(0, len(data), chunk_size): - self._write_queue.put(data[i:i + chunk_size]) diff --git a/cassandra/io/geventreactor.py b/cassandra/io/geventreactor.py deleted file mode 100644 index 7516fdd6df..0000000000 --- a/cassandra/io/geventreactor.py +++ /dev/null @@ -1,139 +0,0 @@ -# Copyright DataStax, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -import gevent -import gevent.event -from gevent.queue import Queue -from gevent import socket -import gevent.ssl - -import logging -import time - - -from cassandra.connection import Connection, ConnectionShutdown, Timer, TimerManager - - -log = logging.getLogger(__name__) - - -class GeventConnection(Connection): - """ - An implementation of :class:`.Connection` that utilizes ``gevent``. - - This implementation assumes all gevent monkey patching is active. It is not tested with partial patching. - """ - - _read_watcher = None - _write_watcher = None - - _socket_impl = gevent.socket - _ssl_impl = gevent.ssl - - _timers = None - _timeout_watcher = None - _new_timer = None - - @classmethod - def initialize_reactor(cls): - if not cls._timers: - cls._timers = TimerManager() - cls._timeout_watcher = gevent.spawn(cls.service_timeouts) - cls._new_timer = gevent.event.Event() - - @classmethod - def create_timer(cls, timeout, callback): - timer = Timer(timeout, callback) - cls._timers.add_timer(timer) - cls._new_timer.set() - return timer - - @classmethod - def service_timeouts(cls): - timer_manager = cls._timers - timer_event = cls._new_timer - while True: - next_end = timer_manager.service_timeouts() - sleep_time = max(next_end - time.time(), 0) if next_end else 10000 - timer_event.wait(sleep_time) - timer_event.clear() - - def __init__(self, *args, **kwargs): - Connection.__init__(self, *args, **kwargs) - - self._write_queue = Queue() - - self._connect_socket() - - self._read_watcher = gevent.spawn(self.handle_read) - self._write_watcher = gevent.spawn(self.handle_write) - self._send_options_message() - - def close(self): - with self.lock: - if self.is_closed: - return - self.is_closed = True - - log.debug("Closing connection (%s) to %s" % (id(self), self.endpoint)) - if self._read_watcher: - self._read_watcher.kill(block=False) - if self._write_watcher: - self._write_watcher.kill(block=False) - if self._socket: - self._socket.close() - log.debug("Closed socket to %s" % (self.endpoint,)) - - if not self.is_defunct: - msg = "Connection to %s was closed" % self.endpoint - if self.last_error: - msg += ": %s" % (self.last_error,) - self.error_all_requests(ConnectionShutdown(msg)) - # don't leave in-progress operations hanging - self.connected_event.set() - - def handle_close(self): - log.debug("connection closed by server") - self.close() - - def handle_write(self): - while True: - try: - next_msg = self._write_queue.get() - self._socket.sendall(next_msg) - except socket.error as err: - log.debug("Exception in send for %s: %s", self, err) - self.defunct(err) - return - - def handle_read(self): - while True: - try: - buf = self._socket.recv(self.in_buffer_size) - self._iobuf.write(buf) - except socket.error as err: - log.debug("Exception in read for %s: %s", self, err) - self.defunct(err) - return # leave the read loop - - if buf and self._iobuf.tell(): - self.process_io_buffer() - else: - log.debug("Connection %s closed by server", self) - self.close() - return - - def push(self, data): - chunk_size = self.out_buffer_size - for i in range(0, len(data), chunk_size): - self._write_queue.put(data[i:i + chunk_size]) diff --git a/cassandra/io/twistedreactor.py b/cassandra/io/twistedreactor.py deleted file mode 100644 index 446200bf63..0000000000 --- a/cassandra/io/twistedreactor.py +++ /dev/null @@ -1,308 +0,0 @@ -# Copyright DataStax, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -""" -Module that implements an event loop based on twisted -( https://twistedmatrix.com ). -""" -import atexit -import logging -import time -from functools import partial -from threading import Thread, Lock -import weakref - -from twisted.internet import reactor, protocol -from twisted.internet.endpoints import connectProtocol, TCP4ClientEndpoint, SSL4ClientEndpoint -from twisted.internet.interfaces import IOpenSSLClientConnectionCreator -from twisted.python.failure import Failure -from zope.interface import implementer - -from cassandra.connection import Connection, ConnectionShutdown, Timer, TimerManager, ConnectionException - -try: - from OpenSSL import SSL - _HAS_SSL = True -except ImportError as e: - _HAS_SSL = False - import_exception = e -log = logging.getLogger(__name__) - - -def _cleanup(cleanup_weakref): - try: - cleanup_weakref()._cleanup() - except ReferenceError: - return - - -class TwistedConnectionProtocol(protocol.Protocol): - """ - Twisted Protocol class for handling data received and connection - made events. - """ - - def __init__(self, connection): - self.connection = connection - - def dataReceived(self, data): - """ - Callback function that is called when data has been received - on the connection. - - Reaches back to the Connection object and queues the data for - processing. - """ - self.connection._iobuf.write(data) - self.connection.handle_read() - - def connectionMade(self): - """ - Callback function that is called when a connection has succeeded. - - Reaches back to the Connection object and confirms that the connection - is ready. - """ - self.connection.client_connection_made(self.transport) - - def connectionLost(self, reason): - # reason is a Failure instance - log.debug("Connect lost: %s", reason) - self.connection.defunct(reason.value) - - -class TwistedLoop(object): - - _lock = None - _thread = None - _timeout_task = None - _timeout = None - - def __init__(self): - self._lock = Lock() - self._timers = TimerManager() - - def maybe_start(self): - with self._lock: - if not reactor.running: - self._thread = Thread(target=reactor.run, - name="cassandra_driver_twisted_event_loop", - kwargs={'installSignalHandlers': False}) - self._thread.daemon = True - self._thread.start() - atexit.register(partial(_cleanup, weakref.ref(self))) - - def _reactor_stopped(self): - return reactor._stopped - - def _cleanup(self): - if self._thread: - reactor.callFromThread(reactor.stop) - self._thread.join(timeout=1.0) - if self._thread.is_alive(): - log.warning("Event loop thread could not be joined, so " - "shutdown may not be clean. Please call " - "Cluster.shutdown() to avoid this.") - log.debug("Event loop thread was joined") - - def add_timer(self, timer): - self._timers.add_timer(timer) - # callFromThread to schedule from the loop thread, where - # the timeout task can safely be modified - reactor.callFromThread(self._schedule_timeout, timer.end) - - def _schedule_timeout(self, next_timeout): - if next_timeout: - delay = max(next_timeout - time.time(), 0) - if self._timeout_task and self._timeout_task.active(): - if next_timeout < self._timeout: - self._timeout_task.reset(delay) - self._timeout = next_timeout - else: - self._timeout_task = reactor.callLater(delay, self._on_loop_timer) - self._timeout = next_timeout - - def _on_loop_timer(self): - self._timers.service_timeouts() - self._schedule_timeout(self._timers.next_timeout) - - -@implementer(IOpenSSLClientConnectionCreator) -class _SSLCreator(object): - def __init__(self, endpoint, ssl_context, ssl_options, check_hostname, timeout): - self.endpoint = endpoint - self.ssl_options = ssl_options - self.check_hostname = check_hostname - self.timeout = timeout - - if ssl_context: - self.context = ssl_context - else: - self.context = SSL.Context(SSL.TLSv1_METHOD) - if "certfile" in self.ssl_options: - self.context.use_certificate_file(self.ssl_options["certfile"]) - if "keyfile" in self.ssl_options: - self.context.use_privatekey_file(self.ssl_options["keyfile"]) - if "ca_certs" in self.ssl_options: - self.context.load_verify_locations(self.ssl_options["ca_certs"]) - if "cert_reqs" in self.ssl_options: - self.context.set_verify( - self.ssl_options["cert_reqs"], - callback=self.verify_callback - ) - self.context.set_info_callback(self.info_callback) - - def verify_callback(self, connection, x509, errnum, errdepth, ok): - return ok - - def info_callback(self, connection, where, ret): - if where & SSL.SSL_CB_HANDSHAKE_DONE: - if self.check_hostname and self.endpoint.address != connection.get_peer_certificate().get_subject().commonName: - transport = connection.get_app_data() - transport.failVerification(Failure(ConnectionException("Hostname verification failed", self.endpoint))) - - def clientConnectionForTLS(self, tlsProtocol): - connection = SSL.Connection(self.context, None) - connection.set_app_data(tlsProtocol) - if self.ssl_options and "server_hostname" in self.ssl_options: - connection.set_tlsext_host_name(self.ssl_options['server_hostname'].encode('ascii')) - return connection - - -class TwistedConnection(Connection): - """ - An implementation of :class:`.Connection` that utilizes the - Twisted event loop. - """ - - _loop = None - - @classmethod - def initialize_reactor(cls): - if not cls._loop: - cls._loop = TwistedLoop() - - @classmethod - def create_timer(cls, timeout, callback): - timer = Timer(timeout, callback) - cls._loop.add_timer(timer) - return timer - - def __init__(self, *args, **kwargs): - """ - Initialization method. - - Note that we can't call reactor methods directly here because - it's not thread-safe, so we schedule the reactor/connection - stuff to be run from the event loop thread when it gets the - chance. - """ - Connection.__init__(self, *args, **kwargs) - - self.is_closed = True - self.connector = None - self.transport = None - - reactor.callFromThread(self.add_connection) - self._loop.maybe_start() - - def _check_pyopenssl(self): - if self.ssl_context or self.ssl_options: - if not _HAS_SSL: - raise ImportError( - str(import_exception) + - ', pyOpenSSL must be installed to enable SSL support with the Twisted event loop' - ) - - def add_connection(self): - """ - Convenience function to connect and store the resulting - connector. - """ - host, port = self.endpoint.resolve() - if self.ssl_context or self.ssl_options: - # Can't use optionsForClientTLS here because it *forces* hostname verification. - # Cool they enforce strong security, but we have to be able to turn it off - self._check_pyopenssl() - - ssl_connection_creator = _SSLCreator( - self.endpoint, - self.ssl_context if self.ssl_context else None, - self.ssl_options, - self._check_hostname, - self.connect_timeout, - ) - - endpoint = SSL4ClientEndpoint( - reactor, - host, - port, - sslContextFactory=ssl_connection_creator, - timeout=self.connect_timeout, - ) - else: - endpoint = TCP4ClientEndpoint( - reactor, - host, - port, - timeout=self.connect_timeout - ) - connectProtocol(endpoint, TwistedConnectionProtocol(self)) - - def client_connection_made(self, transport): - """ - Called by twisted protocol when a connection attempt has - succeeded. - """ - with self.lock: - self.is_closed = False - self.transport = transport - self._send_options_message() - - def close(self): - """ - Disconnect and error-out all requests. - """ - with self.lock: - if self.is_closed: - return - self.is_closed = True - - log.debug("Closing connection (%s) to %s", id(self), self.endpoint) - reactor.callFromThread(self.transport.connector.disconnect) - log.debug("Closed socket to %s", self.endpoint) - - if not self.is_defunct: - msg = "Connection to %s was closed" % self.endpoint - if self.last_error: - msg += ": %s" % (self.last_error,) - self.error_all_requests(ConnectionShutdown(msg)) - # don't leave in-progress operations hanging - self.connected_event.set() - - def handle_read(self): - """ - Process the incoming data buffer. - """ - self.process_io_buffer() - - def push(self, data): - """ - This function is called when outgoing data should be queued - for sending. - - Note that we can't call transport.write() directly because - it is not thread-safe, so we schedule it to run from within - the event loop when it gets the chance. - """ - reactor.callFromThread(self.transport.write, data) diff --git a/docs/api/cassandra/io/eventletreactor.rst b/docs/api/cassandra/io/eventletreactor.rst deleted file mode 100644 index 2e71153b70..0000000000 --- a/docs/api/cassandra/io/eventletreactor.rst +++ /dev/null @@ -1,9 +0,0 @@ -cassandra.io.eventletreactor -============================ - -``eventlet``-compatible Connection - -.. module:: cassandra.io.eventletreactor - -.. autoclass:: EventletConnection - :members: diff --git a/docs/api/cassandra/io/geventreactor.rst b/docs/api/cassandra/io/geventreactor.rst deleted file mode 100644 index a4b0235c6a..0000000000 --- a/docs/api/cassandra/io/geventreactor.rst +++ /dev/null @@ -1,9 +0,0 @@ -cassandra.io.geventreactor -========================== - -``gevent``-compatible Event Loop - -.. module:: cassandra.io.geventreactor - -.. autoclass:: GeventConnection - :members: diff --git a/docs/api/cassandra/io/twistedreactor.rst b/docs/api/cassandra/io/twistedreactor.rst deleted file mode 100644 index cc6944c9fd..0000000000 --- a/docs/api/cassandra/io/twistedreactor.rst +++ /dev/null @@ -1,11 +0,0 @@ -cassandra.io.twistedreactor -=========================== - -Twisted Event Loop - -.. module:: cassandra.io.twistedreactor - -.. class:: TwistedConnection - - An implementation of :class:`~cassandra.io.connection.Connection` that uses - Twisted's reactor as its event loop. diff --git a/docs/api/index.rst b/docs/api/index.rst index cecbea5e75..f63534e532 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -23,10 +23,7 @@ Core Driver cassandra/timestamps cassandra/io/asyncioreactor cassandra/io/asyncorereactor - cassandra/io/eventletreactor cassandra/io/libevreactor - cassandra/io/geventreactor - cassandra/io/twistedreactor .. _om_api: diff --git a/docs/installation.rst b/docs/installation.rst index b3a79f2940..415fb5c316 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -169,10 +169,12 @@ dependencies, then install the driver:: Supported Event Loops ^^^^^^^^^^^^^^^^^^^^^ -For Python versions before 3.12 the driver uses the ``asyncore`` module for its default -event loop. Other event loops such as ``libev``, ``gevent`` and ``eventlet`` are also -available via Python modules or C extensions. Python 3.12 has removed ``asyncore`` entirely -so for this platform one of these other event loops must be used. +The ``asyncore`` and ``libev`` event loops are proven production-grade event loops. Python 3.12 removed +asyncore from the runtime but this event loop can still be used in newer versions of Python via the +`pyasyncore `_ package. + +The ``asyncio`` event loop is generally functional but still somewhat experimental and not recommended +for production systems. libev support ^^^^^^^^^^^^^ diff --git a/docs/pyproject.toml b/docs/pyproject.toml index f49bc3f520..d5205a599b 100644 --- a/docs/pyproject.toml +++ b/docs/pyproject.toml @@ -7,8 +7,6 @@ package-mode = false requires-python = ">=3.13,<3.14" dependencies = [ - "eventlet>=0.40.3,<1.0.0", - "gevent>=25.9.1,<26.0.0", "gremlinpython==3.7.4", "pygments>=2.19.2,<3.0.0", "myst-parser>=5.0.0", diff --git a/docs/security.rst b/docs/security.rst index 5c8645e685..3cbbcbbb40 100644 --- a/docs/security.rst +++ b/docs/security.rst @@ -65,15 +65,6 @@ keystore files with these instructions: * `Scylla TLS/SSL Guide `_ -SSL with Twisted or Eventlet -^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Twisted and Eventlet both use an alternative SSL implementation called pyOpenSSL, so if your `Cluster`'s connection class is -:class:`~cassandra.io.twistedreactor.TwistedConnection` or :class:`~cassandra.io.eventletreactor.EventletConnection`, you must pass a -`pyOpenSSL context `_ instead. -An example is provided in these docs, and more details can be found in the -`documentation `_. -pyOpenSSL is not installed by the driver and must be installed separately. - SSL Configuration Examples ^^^^^^^^^^^^^^^^^^^^^^^^^^ Here, we'll describe the server and driver configuration necessary to set up SSL to meet various goals, such as the client verifying the server and the server verifying the client. We'll also include Python code demonstrating how to use servers and drivers configured in these ways. @@ -246,32 +237,6 @@ The following driver code specifies that the connection should use two-way verif The driver uses ``SSLContext`` directly to give you many other options in configuring SSL. Consider reading the `Python SSL documentation `__ for more details about ``SSLContext`` configuration. -**Server verifies client and client verifies server using Twisted and pyOpenSSL** - -.. code-block:: python - - from OpenSSL import SSL, crypto - from cassandra.cluster import Cluster - from cassandra.io.twistedreactor import TwistedConnection - - ssl_context = SSL.Context(SSL.TLSv1_2_METHOD) - ssl_context.set_verify(SSL.VERIFY_PEER, callback=lambda _1, _2, _3, _4, ok: ok) - ssl_context.use_certificate_file('/path/to/client.crt_signed') - ssl_context.use_privatekey_file('/path/to/client.key') - ssl_context.load_verify_locations('/path/to/rootca.crt') - - cluster = Cluster( - contact_points=['127.0.0.1'], - connection_class=TwistedConnection, - ssl_context=ssl_context, - ssl_options={'check_hostname': True} - ) - session = cluster.connect() - - -Connecting using Eventlet would look similar except instead of importing and using ``TwistedConnection``, you would -import and use ``EventletConnection``, including the appropriate monkey-patching. - Versions 3.16.0 and lower ^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -303,8 +268,3 @@ This is only an example to show how to pass the ssl parameters. Consider reading the `python ssl documentation `__ for your configuration. -SSL with Twisted -++++++++++++++++ - -In case the twisted event loop is used pyOpenSSL must be installed or an exception will be risen. Also -to set the ``ssl_version`` and ``cert_reqs`` in ``ssl_opts`` the appropriate constants from pyOpenSSL are expected. diff --git a/docs/uv.lock b/docs/uv.lock index 16e14fdd51..8a56de64fa 100644 --- a/docs/uv.lock +++ b/docs/uv.lock @@ -160,20 +160,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] -[[package]] -name = "cffi" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, -] - [[package]] name = "charset-normalizer" version = "3.4.7" @@ -220,15 +206,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] -[[package]] -name = "dnspython" -version = "2.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, -] - [[package]] name = "docutils" version = "0.22.4" @@ -238,19 +215,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, ] -[[package]] -name = "eventlet" -version = "0.41.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "dnspython" }, - { name = "greenlet" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d3/90/32772ae7c9897554c56b9367b67478a3dc89c70d9b4d12e241746f6fdae3/eventlet-0.41.0.tar.gz", hash = "sha256:35df85f0ccd3e73effb6fd9f1ceae46b500b966c7da1817289c323a307bd397b", size = 565911, upload-time = "2026-04-02T07:33:23.605Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/1c/febe9acf1b4f0d67603b231c28d6d17d647d68c90c1963fecdeb64046d6d/eventlet-0.41.0-py3-none-any.whl", hash = "sha256:bc22396093cb4119ff7007776be6a5348a613ccd42eeb0f9519853a6efcbcabe", size = 364574, upload-time = "2026-04-02T07:33:21.756Z" }, -] - [[package]] name = "frozenlist" version = "1.8.0" @@ -292,46 +256,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] -[[package]] -name = "gevent" -version = "25.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, - { name = "greenlet", marker = "platform_python_implementation == 'CPython'" }, - { name = "zope-event" }, - { name = "zope-interface" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9e/48/b3ef2673ffb940f980966694e40d6d32560f3ffa284ecaeb5ea3a90a6d3f/gevent-25.9.1.tar.gz", hash = "sha256:adf9cd552de44a4e6754c51ff2e78d9193b7fa6eab123db9578a210e657235dd", size = 5059025, upload-time = "2025-09-17T16:15:34.528Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/77/b97f086388f87f8ad3e01364f845004aef0123d4430241c7c9b1f9bde742/gevent-25.9.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:4f84591d13845ee31c13f44bdf6bd6c3dbf385b5af98b2f25ec328213775f2ed", size = 2973739, upload-time = "2025-09-17T14:53:30.279Z" }, - { url = "https://files.pythonhosted.org/packages/3c/2e/9d5f204ead343e5b27bbb2fedaec7cd0009d50696b2266f590ae845d0331/gevent-25.9.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9cdbb24c276a2d0110ad5c978e49daf620b153719ac8a548ce1250a7eb1b9245", size = 1809165, upload-time = "2025-09-17T15:41:27.193Z" }, - { url = "https://files.pythonhosted.org/packages/10/3e/791d1bf1eb47748606d5f2c2aa66571f474d63e0176228b1f1fd7b77ab37/gevent-25.9.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:88b6c07169468af631dcf0fdd3658f9246d6822cc51461d43f7c44f28b0abb82", size = 1890638, upload-time = "2025-09-17T15:49:02.45Z" }, - { url = "https://files.pythonhosted.org/packages/f2/5c/9ad0229b2b4d81249ca41e4f91dd8057deaa0da6d4fbe40bf13cdc5f7a47/gevent-25.9.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b7bb0e29a7b3e6ca9bed2394aa820244069982c36dc30b70eb1004dd67851a48", size = 1857118, upload-time = "2025-09-17T15:49:22.125Z" }, - { url = "https://files.pythonhosted.org/packages/49/2a/3010ed6c44179a3a5c5c152e6de43a30ff8bc2c8de3115ad8733533a018f/gevent-25.9.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2951bb070c0ee37b632ac9134e4fdaad70d2e660c931bb792983a0837fe5b7d7", size = 2111598, upload-time = "2025-09-17T15:15:15.226Z" }, - { url = "https://files.pythonhosted.org/packages/08/75/6bbe57c19a7aa4527cc0f9afcdf5a5f2aed2603b08aadbccb5bf7f607ff4/gevent-25.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e4e17c2d57e9a42e25f2a73d297b22b60b2470a74be5a515b36c984e1a246d47", size = 1829059, upload-time = "2025-09-17T15:52:42.596Z" }, - { url = "https://files.pythonhosted.org/packages/06/6e/19a9bee9092be45679cb69e4dd2e0bf5f897b7140b4b39c57cc123d24829/gevent-25.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8d94936f8f8b23d9de2251798fcb603b84f083fdf0d7f427183c1828fb64f117", size = 2173529, upload-time = "2025-09-17T15:24:13.897Z" }, - { url = "https://files.pythonhosted.org/packages/ca/4f/50de9afd879440e25737e63f5ba6ee764b75a3abe17376496ab57f432546/gevent-25.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:eb51c5f9537b07da673258b4832f6635014fee31690c3f0944d34741b69f92fa", size = 1681518, upload-time = "2025-09-17T19:39:47.488Z" }, -] - -[[package]] -name = "greenlet" -version = "3.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/86/94/a5935717b307d7c71fe877b52b884c6af707d2d2090db118a03fbd799369/greenlet-3.4.0.tar.gz", hash = "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff", size = 195913, upload-time = "2026-04-08T17:08:00.863Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/75/7e9cd1126a1e1f0cd67b0eda02e5221b28488d352684704a78ed505bd719/greenlet-3.4.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:43748988b097f9c6f09364f260741aa73c80747f63389824435c7a50bfdfd5c1", size = 285856, upload-time = "2026-04-08T15:52:45.82Z" }, - { url = "https://files.pythonhosted.org/packages/9d/c4/3e2df392e5cb199527c4d9dbcaa75c14edcc394b45040f0189f649631e3c/greenlet-3.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5566e4e2cd7a880e8c27618e3eab20f3494452d12fd5129edef7b2f7aa9a36d1", size = 610208, upload-time = "2026-04-08T16:24:39.674Z" }, - { url = "https://files.pythonhosted.org/packages/da/af/750cdfda1d1bd30a6c28080245be8d0346e669a98fdbae7f4102aa95fff3/greenlet-3.4.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1054c5a3c78e2ab599d452f23f7adafef55062a783a8e241d24f3b633ba6ff82", size = 621269, upload-time = "2026-04-08T16:30:59.767Z" }, - { url = "https://files.pythonhosted.org/packages/e0/93/c8c508d68ba93232784bbc1b5474d92371f2897dfc6bc281b419f2e0d492/greenlet-3.4.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98eedd1803353daf1cd9ef23eef23eda5a4d22f99b1f998d273a8b78b70dd47f", size = 628455, upload-time = "2026-04-08T16:40:40.698Z" }, - { url = "https://files.pythonhosted.org/packages/54/78/0cbc693622cd54ebe25207efbb3a0eb07c2639cb8594f6e3aaaa0bb077a8/greenlet-3.4.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f82cb6cddc27dd81c96b1506f4aa7def15070c3b2a67d4e46fd19016aacce6cf", size = 617549, upload-time = "2026-04-08T15:56:34.893Z" }, - { url = "https://files.pythonhosted.org/packages/7f/46/cfaaa0ade435a60550fd83d07dfd5c41f873a01da17ede5c4cade0b9bab8/greenlet-3.4.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:b7857e2202aae67bc5725e0c1f6403c20a8ff46094ece015e7d474f5f7020b55", size = 426238, upload-time = "2026-04-08T16:43:06.865Z" }, - { url = "https://files.pythonhosted.org/packages/ba/c0/8966767de01343c1ff47e8b855dc78e7d1a8ed2b7b9c83576a57e289f81d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:227a46251ecba4ff46ae742bc5ce95c91d5aceb4b02f885487aff269c127a729", size = 1575310, upload-time = "2026-04-08T16:26:21.671Z" }, - { url = "https://files.pythonhosted.org/packages/b8/38/bcdc71ba05e9a5fda87f63ffc2abcd1f15693b659346df994a48c968003d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5b99e87be7eba788dd5b75ba1cde5639edffdec5f91fe0d734a249535ec3408c", size = 1640435, upload-time = "2026-04-08T15:57:32.572Z" }, - { url = "https://files.pythonhosted.org/packages/a1/c2/19b664b7173b9e4ef5f77e8cef9f14c20ec7fce7920dc1ccd7afd955d093/greenlet-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:849f8bc17acd6295fcb5de8e46d55cc0e52381c56eaf50a2afd258e97bc65940", size = 238760, upload-time = "2026-04-08T17:04:03.878Z" }, - { url = "https://files.pythonhosted.org/packages/9b/96/795619651d39c7fbd809a522f881aa6f0ead504cc8201c3a5b789dfaef99/greenlet-3.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:9390ad88b652b1903814eaabd629ca184db15e0eeb6fe8a390bbf8b9106ae15a", size = 235498, upload-time = "2026-04-08T17:05:00.584Z" }, -] - [[package]] name = "gremlinpython" version = "3.7.4" @@ -611,15 +535,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] -[[package]] -name = "pycparser" -version = "3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, -] - [[package]] name = "pygments" version = "2.20.0" @@ -634,8 +549,6 @@ name = "python-driver-docs" version = "0.1.0" source = { editable = "." } dependencies = [ - { name = "eventlet" }, - { name = "gevent" }, { name = "gremlinpython" }, { name = "myst-parser" }, { name = "pygments" }, @@ -656,8 +569,6 @@ dev = [ [package.metadata] requires-dist = [ - { name = "eventlet", specifier = ">=0.40.3,<1.0.0" }, - { name = "gevent", specifier = ">=25.9.1,<26.0.0" }, { name = "gremlinpython", specifier = "==3.7.4" }, { name = "myst-parser", specifier = ">=5.0.0" }, { name = "pygments", specifier = ">=2.19.2,<3.0.0" }, @@ -1243,26 +1154,3 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" }, { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, ] - -[[package]] -name = "zope-event" -version = "6.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/46/33/d3eeac228fc14de76615612ee208be2d8a5b5b0fada36bf9b62d6b40600c/zope_event-6.1.tar.gz", hash = "sha256:6052a3e0cb8565d3d4ef1a3a7809336ac519bc4fe38398cb8d466db09adef4f0", size = 18739, upload-time = "2025-11-07T08:05:49.934Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/b0/956902e5e1302f8c5d124e219c6bf214e2649f92ad5fce85b05c039a04c9/zope_event-6.1-py3-none-any.whl", hash = "sha256:0ca78b6391b694272b23ec1335c0294cc471065ed10f7f606858fc54566c25a0", size = 6414, upload-time = "2025-11-07T08:05:48.874Z" }, -] - -[[package]] -name = "zope-interface" -version = "8.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c9/04/0b1d92e7d31507c5fbe203d9cc1ae80fb0645688c7af751ea0ec18c2223e/zope_interface-8.3.tar.gz", hash = "sha256:e1a9de7d0b5b5c249a73b91aebf4598ce05e334303af6aa94865893283e9ff10", size = 256822, upload-time = "2026-04-10T06:12:35.036Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/27/da/ff205c5463e52ad64cc40be667fdff2b01b9754a385c6b95bac01645fa4f/zope_interface-8.3-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:1aa0e1d72212cedc38b2156bbca08cf24625c057135a7947ef6b19bc732b2772", size = 211889, upload-time = "2026-04-10T06:22:27.612Z" }, - { url = "https://files.pythonhosted.org/packages/c7/21/0cc848e22769b1cf4c0cd636ec2e60ea05cfb958423435ea526d5a291fe8/zope_interface-8.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54ab83218a8f6947ba4b6cb1a121f1e1abe2e418b838ccdac71639d0f97e734e", size = 211961, upload-time = "2026-04-10T06:22:29.575Z" }, - { url = "https://files.pythonhosted.org/packages/e3/54/815c9dbb90336c50694b4c7ef7ced06bc389e5597200c77457b557a0221c/zope_interface-8.3-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:34d6c10fa790005487c471e0e4ab537b0fa9a70e55a96994e51ffeef92205fa4", size = 264409, upload-time = "2026-04-10T06:22:31.426Z" }, - { url = "https://files.pythonhosted.org/packages/3a/69/2e5c30adde0e94552d934971fa6eba107449d3d11fa086cfcfeb8ea6354d/zope_interface-8.3-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:93108d5f8dee20177a637438bf4df4c6faf8a317c9d4a8b1d5e78123854e3317", size = 269592, upload-time = "2026-04-10T06:22:33.393Z" }, - { url = "https://files.pythonhosted.org/packages/23/8a/fbb1dceb5c5400b2b27934aa102d29fe4cb06732122e7f409efebeb6e097/zope_interface-8.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8f81d90f80b9fbf36602549e2f187861c9d7139837f8c9dd685ce3b933c6360f", size = 269548, upload-time = "2026-04-10T06:22:35.339Z" }, - { url = "https://files.pythonhosted.org/packages/a2/70/abd0bb9cc9b1a9a718f30c81f46a184a2e751dd80cf57db142ffa42730da/zope_interface-8.3-cp313-cp313-win_amd64.whl", hash = "sha256:96106a5f609bb355e1aec6ab0361213c8af0843ca1e1ba9c42eacfbd0910914e", size = 214391, upload-time = "2026-04-10T06:22:36.969Z" }, -] diff --git a/tests/__init__.py b/tests/__init__.py index 0966d5041e..cb116be259 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -14,8 +14,6 @@ import unittest import logging -import sys -import socket import platform import os from concurrent.futures import ThreadPoolExecutor @@ -24,30 +22,6 @@ log = logging.getLogger() -def is_eventlet_monkey_patched(): - if 'eventlet.patcher' not in sys.modules: - return False - try: - import eventlet.patcher - return eventlet.patcher.is_monkey_patched('socket') - # Yet another case related to PYTHON-1364 - except AttributeError: - return False - -def is_gevent_monkey_patched(): - if 'gevent.monkey' not in sys.modules: - return False - try: - import gevent.socket - except AttributeError: - return False - return socket.socket is gevent.socket.socket - - -def is_monkey_patched(): - return is_gevent_monkey_patched() or is_eventlet_monkey_patched() - -MONKEY_PATCH_LOOP = bool(os.getenv('MONKEY_PATCH_LOOP', False)) EVENT_LOOP_MANAGER = os.getenv('EVENT_LOOP_MANAGER', '') @@ -60,30 +34,9 @@ def is_monkey_patched(): thread_pool_executor_class = ThreadPoolExecutor -if "gevent" in EVENT_LOOP_MANAGER: - import gevent.monkey - gevent.monkey.patch_all() - from cassandra.io.geventreactor import GeventConnection - connection_class = GeventConnection -elif "eventlet" in EVENT_LOOP_MANAGER: - from eventlet import monkey_patch - monkey_patch() - - from cassandra.io.eventletreactor import EventletConnection - connection_class = EventletConnection - - try: - from futurist import GreenThreadPoolExecutor - thread_pool_executor_class = GreenThreadPoolExecutor - except: - # futurist is installed only with python >=3.7 - pass -elif "asyncore" in EVENT_LOOP_MANAGER: +if "asyncore" in EVENT_LOOP_MANAGER: from cassandra.io.asyncorereactor import AsyncoreConnection connection_class = AsyncoreConnection -elif "twisted" in EVENT_LOOP_MANAGER: - from cassandra.io.twistedreactor import TwistedConnection - connection_class = TwistedConnection elif "asyncio" in EVENT_LOOP_MANAGER: from cassandra.io.asyncioreactor import AsyncioConnection connection_class = AsyncioConnection diff --git a/tests/integration/long/test_ipv6.py b/tests/integration/long/test_ipv6.py index 1d2c7b2874..f0216ac787 100644 --- a/tests/integration/long/test_ipv6.py +++ b/tests/integration/long/test_ipv6.py @@ -23,7 +23,6 @@ except DependencyException: AsyncoreConnection = None -from tests import is_monkey_patched from tests.integration import use_cluster, remove_cluster, TestCluster try: @@ -32,11 +31,6 @@ LibevConnection = None -if is_monkey_patched(): - LibevConnection = None - AsyncoreConnection = None - - import unittest import pytest diff --git a/tests/integration/long/test_ssl.py b/tests/integration/long/test_ssl.py index 0170f56fa1..df2d7c35a7 100644 --- a/tests/integration/long/test_ssl.py +++ b/tests/integration/long/test_ssl.py @@ -20,10 +20,8 @@ from cassandra import ConsistencyLevel from cassandra.query import SimpleStatement -from OpenSSL import SSL, crypto - from tests.integration import ( - get_cluster, remove_cluster, use_single_node, start_cluster_wait_for_up, EVENT_LOOP_MANAGER, TestCluster + get_cluster, remove_cluster, use_single_node, start_cluster_wait_for_up, TestCluster ) import pytest @@ -49,20 +47,9 @@ DRIVER_CERTFILE = os.path.abspath("tests/integration/long/ssl/client.crt_signed") DRIVER_CERTFILE_BAD = os.path.abspath("tests/integration/long/ssl/client_bad.key") -USES_PYOPENSSL = "twisted" in EVENT_LOOP_MANAGER or "eventlet" in EVENT_LOOP_MANAGER -if "twisted" in EVENT_LOOP_MANAGER: - import OpenSSL - ssl_version = OpenSSL.SSL.TLS_METHOD - verify_certs = {'cert_reqs': SSL.VERIFY_PEER, - 'check_hostname': True} -else: - ssl_version = ssl.PROTOCOL_TLS - verify_certs = {'cert_reqs': ssl.CERT_REQUIRED, - 'check_hostname': True} - - -def verify_callback(connection, x509, errnum, errdepth, ok): - return ok +ssl_version = ssl.PROTOCOL_TLS +verify_certs = {'cert_reqs': ssl.CERT_REQUIRED, + 'check_hostname': True} def setup_cluster_ssl(client_auth=False): @@ -311,17 +298,10 @@ def test_cannot_connect_with_bad_client_auth(self): ssl_options = {'ca_certs': CLIENT_CA_CERTS, 'ssl_version': ssl_version, - 'keyfile': DRIVER_KEYFILE} - - if not USES_PYOPENSSL: - # I don't set the bad certfile for pyopenssl because it hangs - ssl_options['certfile'] = DRIVER_CERTFILE_BAD + 'keyfile': DRIVER_KEYFILE, + 'certfile': DRIVER_CERTFILE_BAD} - cluster = TestCluster( - ssl_options={'ca_certs': CLIENT_CA_CERTS, - 'ssl_version': ssl_version, - 'keyfile': DRIVER_KEYFILE} - ) + cluster = TestCluster(ssl_options=ssl_options) with pytest.raises(NoHostAvailable): cluster.connect() @@ -401,13 +381,9 @@ def test_can_connect_with_sslcontext_certificate(self): @test_category connection:ssl """ - if USES_PYOPENSSL: - ssl_context = SSL.Context(SSL.TLS_CLIENT_METHOD) - ssl_context.load_verify_locations(CLIENT_CA_CERTS) - else: - ssl_context = ssl.SSLContext(ssl_version) - ssl_context.load_verify_locations(CLIENT_CA_CERTS) - ssl_context.verify_mode = ssl.CERT_REQUIRED + ssl_context = ssl.SSLContext(ssl_version) + ssl_context.load_verify_locations(CLIENT_CA_CERTS) + ssl_context.verify_mode = ssl.CERT_REQUIRED validate_ssl_options(ssl_context=ssl_context) def test_can_connect_with_ssl_client_auth_password_private_key(self): @@ -425,19 +401,11 @@ def test_can_connect_with_ssl_client_auth_password_private_key(self): abs_driver_certfile = os.path.abspath(DRIVER_CERTFILE) ssl_options = {} - if USES_PYOPENSSL: - ssl_context = SSL.Context(SSL.TLS_CLIENT_METHOD) - ssl_context.use_certificate_file(abs_driver_certfile) - with open(abs_driver_keyfile) as keyfile: - key = crypto.load_privatekey(crypto.FILETYPE_PEM, keyfile.read(), b'cassandra') - ssl_context.use_privatekey(key) - ssl_context.set_verify(SSL.VERIFY_NONE, verify_callback) - else: - ssl_context = ssl.SSLContext(ssl_version) - ssl_context.load_cert_chain(certfile=abs_driver_certfile, - keyfile=abs_driver_keyfile, - password="cassandra") - ssl_context.verify_mode = ssl.CERT_NONE + ssl_context = ssl.SSLContext(ssl_version) + ssl_context.load_cert_chain(certfile=abs_driver_certfile, + keyfile=abs_driver_keyfile, + password="cassandra") + ssl_context.verify_mode = ssl.CERT_NONE validate_ssl_options(ssl_context=ssl_context, ssl_options=ssl_options) def test_can_connect_with_ssl_context_ca_host_match(self): @@ -446,52 +414,33 @@ def test_can_connect_with_ssl_context_ca_host_match(self): using client auth, an encrypted keyfile, and host matching """ ssl_options = {} - if USES_PYOPENSSL: - ssl_context = SSL.Context(SSL.TLS_CLIENT_METHOD) - ssl_context.use_certificate_file(DRIVER_CERTFILE) - with open(DRIVER_KEYFILE_ENCRYPTED) as keyfile: - key = crypto.load_privatekey(crypto.FILETYPE_PEM, keyfile.read(), b'cassandra') - ssl_context.use_privatekey(key) - ssl_context.load_verify_locations(CLIENT_CA_CERTS) - ssl_options["check_hostname"] = True - else: - ssl_context = ssl.SSLContext(ssl_version) - ssl_context.verify_mode = ssl.CERT_REQUIRED - ssl_context.load_verify_locations(CLIENT_CA_CERTS) - ssl_context.load_cert_chain( - certfile=DRIVER_CERTFILE, - keyfile=DRIVER_KEYFILE_ENCRYPTED, - password="cassandra", - ) - ssl_context.verify_mode = ssl.CERT_REQUIRED - ssl_options["check_hostname"] = True + ssl_context = ssl.SSLContext(ssl_version) + ssl_context.verify_mode = ssl.CERT_REQUIRED + ssl_context.load_verify_locations(CLIENT_CA_CERTS) + ssl_context.load_cert_chain( + certfile=DRIVER_CERTFILE, + keyfile=DRIVER_KEYFILE_ENCRYPTED, + password="cassandra", + ) + ssl_context.verify_mode = ssl.CERT_REQUIRED + ssl_options["check_hostname"] = True validate_ssl_options(ssl_context=ssl_context, ssl_options=ssl_options) def test_cannot_connect_ssl_context_with_invalid_hostname(self): ssl_options = {} - if USES_PYOPENSSL: - ssl_context = SSL.Context(SSL.TLS_CLIENT_METHOD) - ssl_context.use_certificate_file(DRIVER_CERTFILE) - with open(DRIVER_KEYFILE_ENCRYPTED) as keyfile: - key = crypto.load_privatekey(crypto.FILETYPE_PEM, keyfile.read(), b"cassandra") - ssl_context.use_privatekey(key) - ssl_context.load_verify_locations(CLIENT_CA_CERTS) - ssl_options["check_hostname"] = True - else: - ssl_context = ssl.SSLContext(ssl_version) - ssl_context.verify_mode = ssl.CERT_REQUIRED - ssl_context.load_verify_locations(CLIENT_CA_CERTS) - ssl_context.load_cert_chain( - certfile=DRIVER_CERTFILE, - keyfile=DRIVER_KEYFILE_ENCRYPTED, - password="cassandra", - ) - ssl_context.verify_mode = ssl.CERT_REQUIRED - ssl_options["check_hostname"] = True + ssl_context = ssl.SSLContext(ssl_version) + ssl_context.verify_mode = ssl.CERT_REQUIRED + ssl_context.load_verify_locations(CLIENT_CA_CERTS) + ssl_context.load_cert_chain( + certfile=DRIVER_CERTFILE, + keyfile=DRIVER_KEYFILE_ENCRYPTED, + password="cassandra", + ) + ssl_context.verify_mode = ssl.CERT_REQUIRED + ssl_options["check_hostname"] = True with pytest.raises(Exception): validate_ssl_options(ssl_context=ssl_context, ssl_options=ssl_options, hostname="localhost") - @unittest.skipIf(USES_PYOPENSSL, "This test is for the built-in ssl.Context") def test_can_connect_with_sslcontext_default_context(self): """ Test to validate that we are able to connect to a cluster using a SSLContext created from create_default_context(). diff --git a/tests/integration/standard/test_connection.py b/tests/integration/standard/test_connection.py index df0f568c2c..5a33ef1d0d 100644 --- a/tests/integration/standard/test_connection.py +++ b/tests/integration/standard/test_connection.py @@ -29,7 +29,6 @@ from cassandra.protocol import QueryMessage from cassandra.policies import HostFilterPolicy, RoundRobinPolicy, HostStateListener -from tests import is_monkey_patched from tests.integration import use_singledc, get_node, CASSANDRA_IP, local, \ requiresmallclockgranularity, greaterthancass20, TestCluster from tests.util import wait_until @@ -441,8 +440,6 @@ class AsyncoreConnectionTests(ConnectionTests, unittest.TestCase): event_loop_name = "asyncore_cassandra_driver_event_loop" def setUp(self): - if is_monkey_patched(): - raise unittest.SkipTest("Can't test asyncore with monkey patching") if AsyncoreConnection is None: raise unittest.SkipTest('Unable to import asyncore module') ConnectionTests.setUp(self) @@ -458,8 +455,6 @@ class LibevConnectionTests(ConnectionTests, unittest.TestCase): event_loop_name = "event_loop" def setUp(self): - if is_monkey_patched(): - raise unittest.SkipTest("Can't test libev with monkey patching") if LibevConnection is None: raise unittest.SkipTest( 'libev does not appear to be installed properly') diff --git a/tests/unit/io/eventlet_utils.py b/tests/unit/io/eventlet_utils.py deleted file mode 100644 index 2a5a8c78d0..0000000000 --- a/tests/unit/io/eventlet_utils.py +++ /dev/null @@ -1,42 +0,0 @@ -# Copyright DataStax, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -import os -import select -import socket -import _thread as thread -import queue as Queue -import builtins as __builtin__ - -import threading -import ssl -import time -import eventlet -from importlib import reload - -def eventlet_un_patch_all(): - """ - A method to unpatch eventlet monkey patching used for the reactor tests - """ - - # These are the modules that are loaded by eventlet we reload them all - modules_to_unpatch = [os, select, socket, thread, time, Queue, threading, ssl, __builtin__] - for to_unpatch in modules_to_unpatch: - reload(to_unpatch) - -def restore_saved_module(module): - reload(module) - del eventlet.patcher.already_patched[module.__name__] - diff --git a/tests/unit/io/gevent_utils.py b/tests/unit/io/gevent_utils.py deleted file mode 100644 index a341fd9385..0000000000 --- a/tests/unit/io/gevent_utils.py +++ /dev/null @@ -1,56 +0,0 @@ -# Copyright DataStax, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -from gevent import monkey - - -def gevent_un_patch_all(): - """ - A method to unpatch gevent libraries. These are unloaded - in the same order that gevent monkey patch loads theirs. - Order cannot be arbitrary. This is used in the unit tests to - un monkey patch gevent - """ - restore_saved_module("os") - restore_saved_module("time") - restore_saved_module("thread") - restore_saved_module("threading") - restore_saved_module("_threading_local") - restore_saved_module("stdin") - restore_saved_module("stdout") - restore_saved_module("socket") - restore_saved_module("select") - restore_saved_module("ssl") - restore_saved_module("subprocess") - - -def restore_saved_module(module): - """ - gevent monkey patch keeps a list of all patched modules. - This will restore the original ones - :param module: to unpatch - :return: - """ - - # Check the saved attributes in geven monkey patch - if not (module in monkey.saved): - return - _module = __import__(module) - - # If it exist unpatch it - for attr in monkey.saved[module]: - if hasattr(_module, attr): - setattr(_module, attr, monkey.saved[module][attr]) - diff --git a/tests/unit/io/test_asyncioreactor.py b/tests/unit/io/test_asyncioreactor.py index f3ed942090..6fb1fa9523 100644 --- a/tests/unit/io/test_asyncioreactor.py +++ b/tests/unit/io/test_asyncioreactor.py @@ -6,7 +6,7 @@ AsyncioConnection = None ASYNCIO_AVAILABLE = False -from tests import is_monkey_patched, connection_class +from tests import connection_class from tests.unit.io.utils import TimerCallback, TimerTestMixin from unittest.mock import patch, MagicMock @@ -14,12 +14,10 @@ import unittest import time -skip_me = (is_monkey_patched() or - (not ASYNCIO_AVAILABLE) or +skip_me = ( not ASYNCIO_AVAILABLE or (connection_class is not AsyncioConnection)) -@unittest.skipIf(is_monkey_patched(), 'runtime is monkey patched for another reactor') @unittest.skipIf(connection_class is not AsyncioConnection, 'not running asyncio tests; current connection_class is {}'.format(connection_class)) @unittest.skipUnless(ASYNCIO_AVAILABLE, "asyncio is not available for this runtime") diff --git a/tests/unit/io/test_asyncorereactor.py b/tests/unit/io/test_asyncorereactor.py index c8f979bdb9..fe26da9120 100644 --- a/tests/unit/io/test_asyncorereactor.py +++ b/tests/unit/io/test_asyncorereactor.py @@ -26,18 +26,14 @@ ASYNCCORE_AVAILABLE = False AsyncoreConnection = None -from tests import is_monkey_patched -from tests.unit.io.utils import ReactorTestMixin, TimerTestMixin, noop_if_monkey_patched +from tests.unit.io.utils import ReactorTestMixin, TimerTestMixin @unittest.skipIf(not ASYNCCORE_AVAILABLE, 'asyncore is deprecated') class AsyncorePatcher(unittest.TestCase): @classmethod - @noop_if_monkey_patched def setUpClass(cls): - if is_monkey_patched(): - return AsyncoreConnection.initialize_reactor() socket_patcher = patch('socket.socket', spec=socket.socket) @@ -56,7 +52,6 @@ def setUpClass(cls): cls.patchers = (socket_patcher, channel_patcher) @classmethod - @noop_if_monkey_patched def tearDownClass(cls): for p in cls.patchers: try: @@ -71,8 +66,7 @@ class AsyncoreConnectionTest(ReactorTestMixin, AsyncorePatcher): socket_attr_name = 'socket' def setUp(self): - if is_monkey_patched(): - raise unittest.SkipTest("Can't test asyncore with monkey patching") + super(AsyncoreConnectionTest, self).setUp() @unittest.skipIf(not ASYNCCORE_AVAILABLE, 'asyncore is deprecated') @@ -88,6 +82,4 @@ def _timers(self): return asyncorereactor._global_loop._timers def setUp(self): - if is_monkey_patched(): - raise unittest.SkipTest("Can't test asyncore with monkey patching") super(TestAsyncoreTimer, self).setUp() diff --git a/tests/unit/io/test_eventletreactor.py b/tests/unit/io/test_eventletreactor.py deleted file mode 100644 index d3962196a4..0000000000 --- a/tests/unit/io/test_eventletreactor.py +++ /dev/null @@ -1,77 +0,0 @@ -# Copyright DataStax, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -import unittest - -from unittest.mock import patch - -from tests.unit.io.utils import TimerTestMixin -from tests import notpypy, EVENT_LOOP_MANAGER - - -try: - from eventlet import monkey_patch - from cassandra.io.eventletreactor import EventletConnection -except (ImportError, AttributeError): - EventletConnection = None # noqa - -skip_condition = EventletConnection is None or EVENT_LOOP_MANAGER != "eventlet" -# There are some issues with some versions of pypy and eventlet -@notpypy -@unittest.skipIf(skip_condition, "Skipping the eventlet tests because it's not installed") -class EventletTimerTest(TimerTestMixin, unittest.TestCase): - - connection_class = EventletConnection - - @classmethod - def setUpClass(cls): - # This is run even though the class is skipped, so we need - # to make sure no monkey patching is happening - if skip_condition: - return - - # This is being added temporarily due to a bug in eventlet: - # https://github.com/eventlet/eventlet/issues/401 - import eventlet - eventlet.sleep() - monkey_patch() - # cls.connection_class = EventletConnection - - EventletConnection.initialize_reactor() - assert EventletConnection._timers is not None - - def setUp(self): - socket_patcher = patch('eventlet.green.socket.socket') - self.addCleanup(socket_patcher.stop) - socket_patcher.start() - - super(EventletTimerTest, self).setUp() - - recv_patcher = patch.object(self.connection._socket, - 'recv', - return_value=b'') - self.addCleanup(recv_patcher.stop) - recv_patcher.start() - - @property - def create_timer(self): - return self.connection.create_timer - - @property - def _timers(self): - return self.connection._timers - - # There is no unpatching because there is not a clear way - # of doing it reliably diff --git a/tests/unit/io/test_geventreactor.py b/tests/unit/io/test_geventreactor.py deleted file mode 100644 index 58aa02869d..0000000000 --- a/tests/unit/io/test_geventreactor.py +++ /dev/null @@ -1,64 +0,0 @@ -# Copyright DataStax, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import unittest -from unittest.mock import patch - - -from tests.unit.io.utils import TimerTestMixin -from tests import EVENT_LOOP_MANAGER -try: - from cassandra.io.geventreactor import GeventConnection - import gevent.monkey -except ImportError: - GeventConnection = None # noqa - - -skip_condition = GeventConnection is None or EVENT_LOOP_MANAGER != "gevent" -@unittest.skipIf(skip_condition, "Skipping the gevent tests because it's not installed") -class GeventTimerTest(TimerTestMixin, unittest.TestCase): - - connection_class = GeventConnection - - @classmethod - def setUpClass(cls): - # This is run even though the class is skipped, so we need - # to make sure no monkey patching is happening - if skip_condition: - return - # There is no unpatching because there is not a clear way - # of doing it reliably - gevent.monkey.patch_all() - GeventConnection.initialize_reactor() - - def setUp(self): - socket_patcher = patch('gevent.socket.socket') - self.addCleanup(socket_patcher.stop) - socket_patcher.start() - - super(GeventTimerTest, self).setUp() - - recv_patcher = patch.object(self.connection._socket, - 'recv', - return_value=b'') - self.addCleanup(recv_patcher.stop) - recv_patcher.start() - - @property - def create_timer(self): - return self.connection.create_timer - - @property - def _timers(self): - return self.connection._timers diff --git a/tests/unit/io/test_libevreactor.py b/tests/unit/io/test_libevreactor.py index a228a71de8..000930fd43 100644 --- a/tests/unit/io/test_libevreactor.py +++ b/tests/unit/io/test_libevreactor.py @@ -24,8 +24,7 @@ except (ImportError, DependencyException): LibevConnection = None # noqa -from tests import is_monkey_patched -from tests.unit.io.utils import ReactorTestMixin, TimerTestMixin, noop_if_monkey_patched +from tests.unit.io.utils import ReactorTestMixin, TimerTestMixin class LibevConnectionTest(ReactorTestMixin, unittest.TestCase): @@ -35,8 +34,6 @@ class LibevConnectionTest(ReactorTestMixin, unittest.TestCase): null_handle_function_args = None, 0 def setUp(self): - if is_monkey_patched(): - raise unittest.SkipTest("Can't test libev with monkey patching") if LibevConnection is None: raise unittest.SkipTest('libev does not appear to be installed correctly') LibevConnection.initialize_reactor() @@ -101,7 +98,6 @@ def test_watchers_are_finished(self): class LibevTimerPatcher(unittest.TestCase): @classmethod - @noop_if_monkey_patched def setUpClass(cls): if LibevConnection is None: raise unittest.SkipTest('libev does not appear to be installed correctly') @@ -113,7 +109,6 @@ def setUpClass(cls): p.start() @classmethod - @noop_if_monkey_patched def tearDownClass(cls): for p in cls.patchers: try: @@ -141,8 +136,6 @@ def make_connection(self): return c def setUp(self): - if is_monkey_patched(): - raise unittest.SkipTest("Can't test libev with monkey patching.") if LibevConnection is None: raise unittest.SkipTest('libev does not appear to be installed correctly') diff --git a/tests/unit/io/test_libevreactor_shutdown.py b/tests/unit/io/test_libevreactor_shutdown.py index e2f76f8a3e..af5b420888 100644 --- a/tests/unit/io/test_libevreactor_shutdown.py +++ b/tests/unit/io/test_libevreactor_shutdown.py @@ -34,8 +34,6 @@ except (ImportError, DependencyException): LibevConnection = None -from tests import is_monkey_patched - class LibevAtexitCleanupTest(unittest.TestCase): """ @@ -47,8 +45,6 @@ class LibevAtexitCleanupTest(unittest.TestCase): """ def setUp(self): - if is_monkey_patched(): - raise unittest.SkipTest("Can't test libev with monkey patching") if LibevConnection is None: raise unittest.SkipTest('libev does not appear to be installed correctly') @@ -194,8 +190,6 @@ class LibevShutdownRaceConditionTest(unittest.TestCase): """ def setUp(self): - if is_monkey_patched(): - raise unittest.SkipTest("Can't test libev with monkey patching") if LibevConnection is None: raise unittest.SkipTest('libev does not appear to be installed correctly') diff --git a/tests/unit/io/test_twistedreactor.py b/tests/unit/io/test_twistedreactor.py deleted file mode 100644 index 23d9148e97..0000000000 --- a/tests/unit/io/test_twistedreactor.py +++ /dev/null @@ -1,208 +0,0 @@ -# Copyright DataStax, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import unittest -from unittest.mock import Mock, patch - -from cassandra.connection import DefaultEndPoint - -try: - from twisted.test import proto_helpers - from cassandra.io import twistedreactor - from cassandra.io.twistedreactor import TwistedConnection -except ImportError: - twistedreactor = TwistedConnection = None # NOQA - - -from cassandra.connection import _Frame - -from tests.unit.io.utils import TimerTestMixin - -class TestTwistedTimer(TimerTestMixin, unittest.TestCase): - """ - Simple test class that is used to validate that the TimerManager, and timer - classes function appropriately with the twisted infrastructure - """ - - connection_class = TwistedConnection - - @property - def create_timer(self): - return self.connection.create_timer - - @property - def _timers(self): - return self.connection._loop._timers - - def setUp(self): - if twistedreactor is None: - raise unittest.SkipTest("Twisted libraries not available") - twistedreactor.TwistedConnection.initialize_reactor() - super(TestTwistedTimer, self).setUp() - - -class TestTwistedProtocol(unittest.TestCase): - - def setUp(self): - if twistedreactor is None: - raise unittest.SkipTest("Twisted libraries not available") - twistedreactor.TwistedConnection.initialize_reactor() - self.tr = proto_helpers.StringTransportWithDisconnection() - self.tr.connector = Mock() - self.mock_connection = Mock() - self.obj_ut = twistedreactor.TwistedConnectionProtocol(self.mock_connection) - self.tr.protocol = self.obj_ut - - def tearDown(self): - loop = twistedreactor.TwistedConnection._loop - if loop and not loop._reactor_stopped(): - loop._cleanup() - - def test_makeConnection(self): - """ - Verify that the protocol class notifies the connection - object that a successful connection was made. - """ - self.obj_ut.makeConnection(self.tr) - assert self.mock_connection.client_connection_made.called - - def test_receiving_data(self): - """ - Verify that the dataReceived() callback writes the data to - the connection object's buffer and calls handle_read(). - """ - self.obj_ut.makeConnection(self.tr) - self.obj_ut.dataReceived('foobar') - assert self.mock_connection.handle_read.called - self.mock_connection._iobuf.write.assert_called_with("foobar") - - -class TestTwistedConnection(unittest.TestCase): - def setUp(self): - if twistedreactor is None: - raise unittest.SkipTest("Twisted libraries not available") - if twistedreactor.TwistedConnection._loop: - twistedreactor.TwistedConnection._loop._cleanup() - twistedreactor.TwistedConnection.initialize_reactor() - self.reactor_cft_patcher = patch( - 'twisted.internet.reactor.callFromThread') - self.reactor_run_patcher = patch('twisted.internet.reactor.run') - self.thread_patcher = patch('cassandra.io.twistedreactor.Thread') - # Patch reactor.running to False so maybe_start() always enters - # the branch that spawns the reactor thread. Without this, leaked - # reactor state from prior tests can cause reactor.running to be - # True, making maybe_start() a no-op and the reactor.run mock - # never called — leading to a flaky test_connection_initialization. - self.reactor_running_patcher = patch( - 'twisted.internet.reactor.running', new=False) - self.mock_reactor_cft = self.reactor_cft_patcher.start() - self.mock_reactor_run = self.reactor_run_patcher.start() - self.mock_thread_class = self.thread_patcher.start() - self.mock_thread = self.mock_thread_class.return_value - self.mock_thread.is_alive.return_value = False - self.reactor_running_patcher.start() - self.obj_ut = twistedreactor.TwistedConnection(DefaultEndPoint('1.2.3.4'), - cql_version='3.0.1') - - def tearDown(self): - self.reactor_cft_patcher.stop() - self.reactor_run_patcher.stop() - self.thread_patcher.stop() - self.reactor_running_patcher.stop() - - def test_connection_initialization(self): - """ - Verify that __init__() works correctly. - """ - self.mock_reactor_cft.assert_called_with(self.obj_ut.add_connection) - self.mock_thread_class.assert_called_once_with( - target=self.mock_reactor_run, - name="cassandra_driver_twisted_event_loop", - kwargs={'installSignalHandlers': False}) - self.assertIs(self.mock_thread.daemon, True) - self.mock_thread.start.assert_called_once_with() - - def test_client_connection_made(self): - """ - Verifiy that _send_options_message() is called in - client_connection_made() - """ - self.obj_ut._send_options_message = Mock() - self.obj_ut.client_connection_made(Mock()) - self.obj_ut._send_options_message.assert_called_with() - - @patch('twisted.internet.reactor.connectTCP') - def test_close(self, mock_connectTCP): - """ - Verify that close() disconnects the connector and errors callbacks. - """ - transport = Mock() - self.obj_ut.error_all_requests = Mock() - self.obj_ut.add_connection() - self.obj_ut.client_connection_made(transport) - self.obj_ut.is_closed = False - self.obj_ut.close() - - assert self.obj_ut.connected_event.is_set() - assert self.obj_ut.error_all_requests.called - - def test_handle_read__incomplete(self): - """ - Verify that handle_read() processes incomplete messages properly. - """ - self.obj_ut.process_msg = Mock() - assert self.obj_ut._iobuf.getvalue() == b'' # buf starts empty - # incomplete header - self.obj_ut._iobuf.write(b'\x84\x00\x00\x00\x00') - self.obj_ut.handle_read() - assert self.obj_ut._io_buffer.cql_frame_buffer.getvalue() == b'\x84\x00\x00\x00\x00' - - # full header, but incomplete body - self.obj_ut._iobuf.write(b'\x00\x00\x00\x15') - self.obj_ut.handle_read() - assert self.obj_ut._io_buffer.cql_frame_buffer.getvalue() == b'\x84\x00\x00\x00\x00\x00\x00\x00\x15' - assert self.obj_ut._current_frame.end_pos == 30 - - # verify we never attempted to process the incomplete message - assert not self.obj_ut.process_msg.called - - def test_handle_read__fullmessage(self): - """ - Verify that handle_read() processes complete messages properly. - """ - self.obj_ut.process_msg = Mock() - assert self.obj_ut._iobuf.getvalue() == b'' # buf starts empty - - # write a complete message, plus 'NEXT' (to simulate next message) - # assumes protocol v3+ as default Connection.protocol_version - body = b'this is the drum roll' - extra = b'NEXT' - self.obj_ut._iobuf.write( - b'\x84\x01\x00\x02\x03\x00\x00\x00\x15' + body + extra) - self.obj_ut.handle_read() - assert self.obj_ut._io_buffer.cql_frame_buffer.getvalue() == extra - self.obj_ut.process_msg.assert_called_with( - _Frame(version=4, flags=1, stream=2, opcode=3, body_offset=9, end_pos=9 + len(body)), body) - - @patch('twisted.internet.reactor.connectTCP') - def test_push(self, mock_connectTCP): - """ - Verifiy that push() calls transport.write(data). - """ - self.obj_ut.add_connection() - transport_mock = Mock() - self.obj_ut.transport = transport_mock - self.obj_ut.push('123 pickup') - self.mock_reactor_cft.assert_called_with( - transport_mock.write, '123 pickup') diff --git a/tests/unit/io/utils.py b/tests/unit/io/utils.py index b821ee1897..db71d3fd0a 100644 --- a/tests/unit/io/utils.py +++ b/tests/unit/io/utils.py @@ -20,7 +20,6 @@ write_stringmultimap, write_int, write_string, SupportedMessage, ReadyMessage, ServerError ) from cassandra.connection import DefaultEndPoint -from tests import is_monkey_patched import io import random @@ -133,17 +132,6 @@ def submit_and_wait_for_completion(unit_test, create_timer, start, end, incremen for callback in completed_callbacks: assert callback.expected_wait == pytest.approx(callback.get_wait_time(), abs=.5) - -def noop_if_monkey_patched(f): - if is_monkey_patched(): - @wraps(f) - def noop(*args, **kwargs): - return - return noop - - return f - - class TimerTestMixin(object): connection_class = connection = None From 1145313ad38048966d9536695b26b815990a0bdc Mon Sep 17 00:00:00 2001 From: Yaniv Kaul Date: Sun, 9 Aug 2026 17:26:42 +0300 Subject: [PATCH 135/200] CASSPYTHON-13: Remove stale EventletConnection reference in _create_thread_pool_executor This code path in cassandra/cluster.py was not part of the original upstream removal since it doesn't exist upstream; it's fork-specific. With eventletreactor deleted, the import always failed and the method always fell back to a plain ThreadPoolExecutor, so drop the dead branch. Signed-off-by: Yaniv Michael Kaul --- CONTRIBUTING.rst | 3 +- cassandra/cluster.py | 90 ++------------------------------------------ pyproject.toml | 20 ++++------ 3 files changed, 12 insertions(+), 101 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index e8d0e66ddd..1227e11e30 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -73,8 +73,7 @@ Running Unit Tests Unit tests can be run like so:: uv run pytest tests/unit - EVENT_LOOP_MANAGER=gevent uv run pytest tests/unit/io/test_geventreactor.py - EVENT_LOOP_MANAGER=eventlet uv run pytest tests/unit/io/test_eventletreactor.py + EVENT_LOOP_MANAGER=asyncio CASS_DRIVER_NO_SKIP=1 uv run pytest tests/unit/io/test_asyncioreactor.py You can run a specific test method like so:: diff --git a/cassandra/cluster.py b/cassandra/cluster.py index bcc7852c33..238a06ae08 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -36,7 +36,6 @@ import re import queue import socket -import sys import time from threading import Lock, RLock, Thread, Event import uuid @@ -98,57 +97,11 @@ from cassandra.datastax import cloud as dscloud from cassandra.application_info import ApplicationInfoBase -try: - from cassandra.io.twistedreactor import TwistedConnection -except ImportError: - TwistedConnection = None - -try: - from cassandra.io.eventletreactor import EventletConnection -except (ImportError, AttributeError): - # AttributeError was add for handling python 3.12 https://github.com/eventlet/eventlet/issues/812 - # TODO: remove it when eventlet issue would be fixed - EventletConnection = None - try: from weakref import WeakSet except ImportError: from cassandra.util import WeakSet # NOQA -def _is_gevent_monkey_patched(): - if 'gevent.monkey' not in sys.modules: - return False - try: - import gevent.socket - return socket.socket is gevent.socket.socket # Another case related to PYTHON-1364 - except (AttributeError, ImportError): - return False - -def _try_gevent_import(): - if _is_gevent_monkey_patched(): - from cassandra.io.geventreactor import GeventConnection - return (GeventConnection,None) - else: - return (None,None) - -def _is_eventlet_monkey_patched(): - if 'eventlet.patcher' not in sys.modules: - return False - try: - import eventlet.patcher - return eventlet.patcher.is_monkey_patched('socket') - except (ImportError, AttributeError): - # AttributeError was add for handling python 3.12 https://github.com/eventlet/eventlet/issues/812 - # TODO: remove it when eventlet issue would be fixed - return False - -def _try_eventlet_import(): - if _is_eventlet_monkey_patched(): - from cassandra.io.eventletreactor import EventletConnection - return (EventletConnection,None) - else: - return (None,None) - def _try_libev_import(): try: from cassandra.io.libevreactor import LibevConnection @@ -177,7 +130,7 @@ def _connection_reduce_fn(val,import_fn): excs.append(exc) return (rv or import_result, excs) -conn_fns = (_try_gevent_import, _try_eventlet_import, _try_libev_import, _try_asyncore_import, _try_asyncio_import) +conn_fns = (_try_libev_import, _try_asyncore_import, _try_asyncio_import) (conn_class, excs) = reduce(_connection_reduce_fn, conn_fns, (None,[])) if not conn_class: raise DependencyException("Exception loading connection class dependencies", excs) @@ -944,9 +897,6 @@ def default_retry_policy(self, policy): * :class:`cassandra.io.asyncorereactor.AsyncoreConnection` * :class:`cassandra.io.libevreactor.LibevConnection` - * :class:`cassandra.io.eventletreactor.EventletConnection` (requires monkey-patching - see doc for details) - * :class:`cassandra.io.geventreactor.GeventConnection` (requires monkey-patching - see doc for details) - * :class:`cassandra.io.twistedreactor.TwistedConnection` * EXPERIMENTAL: :class:`cassandra.io.asyncioreactor.AsyncioConnection` By default, ``AsyncoreConnection`` will be used, which uses @@ -954,9 +904,6 @@ def default_retry_policy(self, policy): If ``libev`` is installed, ``LibevConnection`` will be used instead. - If ``gevent`` or ``eventlet`` monkey-patching is detected, the corresponding - connection class will be used automatically. - ``AsyncioConnection``, which uses the ``asyncio`` module in the Python standard library, is also available, but currently experimental. Note that it requires ``asyncio`` features that were only introduced in the 3.4 line @@ -1301,9 +1248,7 @@ def __init__(self, raise ValueError("contact_points, endpoint_factory, ssl_context, and ssl_options " "cannot be specified with a cloud configuration") - uses_twisted = TwistedConnection and issubclass(self.connection_class, TwistedConnection) - uses_eventlet = EventletConnection and issubclass(self.connection_class, EventletConnection) - cloud_config = dscloud.get_cloud_config(cloud, create_pyopenssl_context=uses_twisted or uses_eventlet) + cloud_config = dscloud.get_cloud_config(cloud) ssl_context = cloud_config.ssl_context ssl_options = {'check_hostname': True} @@ -1601,39 +1546,12 @@ def _resolve_hostnames(self): def _create_thread_pool_executor(self, **kwargs): """ - Create a ThreadPoolExecutor for the cluster. In most cases, the built-in - `concurrent.futures.ThreadPoolExecutor` is used. - - Python 3.7+ and Eventlet cause the `concurrent.futures.ThreadPoolExecutor` - to hang indefinitely. In that case, the user needs to have the `futurist` - package so we can use the `futurist.GreenThreadPoolExecutor` class instead. + Create a ThreadPoolExecutor for the cluster. :param kwargs: All keyword args are passed to the ThreadPoolExecutor constructor. :return: A ThreadPoolExecutor instance. """ - tpe_class = ThreadPoolExecutor - if sys.version_info[0] >= 3 and sys.version_info[1] >= 7: - try: - from cassandra.io.eventletreactor import EventletConnection - is_eventlet = issubclass(self.connection_class, EventletConnection) - except: - # Eventlet is not available or can't be detected - return tpe_class(**kwargs) - - if is_eventlet: - try: - from futurist import GreenThreadPoolExecutor - tpe_class = GreenThreadPoolExecutor - except ImportError: - # futurist is not available - raise ImportError( - ("Python 3.7+ and Eventlet cause the `concurrent.futures.ThreadPoolExecutor` " - "to hang indefinitely. If you want to use the Eventlet reactor, you " - "need to install the `futurist` package to allow the driver to use " - "the GreenThreadPoolExecutor. See https://github.com/eventlet/eventlet/issues/508 " - "for more details.")) - - return tpe_class(**kwargs) + return ThreadPoolExecutor(**kwargs) def register_user_type(self, keyspace, user_type, klass): """ diff --git a/pyproject.toml b/pyproject.toml index 698ff4c37b..5270281d0c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,13 +45,10 @@ dev = [ "pytest~=8.0", "PyYAML", "pure-sasl", - "twisted[tls]", - "gevent", - "eventlet>=0.33.3", + "cryptography>=42.0", "cython>=3.2", "setuptools", "packaging>=25.0", - "futurist", "pyyaml", "numpy", "objgraph", @@ -165,18 +162,15 @@ test-extras = ["compress-lz4"] # so skipping is disabled (CASS_DRIVER_NO_SKIP=1): a missing dependency such as # libev fails loudly instead of being silently skipped. Tests that cannot run in # the default configuration are listed explicitly: -# * event-loop reactor tests are run separately with the matching -# EVENT_LOOP_MANAGER (gevent/eventlet/asyncio); +# * the asyncio reactor test is run separately with EVENT_LOOP_MANAGER=asyncio; # * asyncore is deprecated and unavailable on modern Python, so it is ignored; # * column_encryption is disabled upstream (scylladb/python-driver#365); # * test_deserialize_date_range_month is disabled upstream (PYTHON-912). -# PyPy uses the pp* override below. All Linux CPython reactor commands run with +# PyPy uses the pp* override below. The Linux CPython reactor command runs with # CASS_DRIVER_NO_SKIP=1 so unexpected skips fail loudly. test-command = [ - "CASS_DRIVER_NO_SKIP=1 pytest --import-mode=append {package}/tests/unit -v --ignore={package}/tests/unit/column_encryption --ignore={package}/tests/unit/io/test_geventreactor.py --ignore={package}/tests/unit/io/test_eventletreactor.py --ignore={package}/tests/unit/io/test_asyncioreactor.py --ignore={package}/tests/unit/io/test_asyncorereactor.py -k 'not test_deserialize_date_range_month'", - "EVENT_LOOP_MANAGER=gevent CASS_DRIVER_NO_SKIP=1 pytest --import-mode=append {package}/tests/unit/io/test_geventreactor.py -v", + "CASS_DRIVER_NO_SKIP=1 pytest --import-mode=append {package}/tests/unit -v --ignore={package}/tests/unit/column_encryption --ignore={package}/tests/unit/io/test_asyncioreactor.py --ignore={package}/tests/unit/io/test_asyncorereactor.py -k 'not test_deserialize_date_range_month'", "EVENT_LOOP_MANAGER=asyncio CASS_DRIVER_NO_SKIP=1 pytest --import-mode=append {package}/tests/unit/io/test_asyncioreactor.py -v", - "EVENT_LOOP_MANAGER=eventlet CASS_DRIVER_NO_SKIP=1 pytest --import-mode=append {package}/tests/unit/io/test_eventletreactor.py -v", ] [tool.cibuildwheel.macos] @@ -185,10 +179,10 @@ build-frontend = "build" test-extras = ["compress-lz4"] # Same policy as Linux (extensions are mandatory here too, libev comes from # Homebrew). The extra -k exclusions are timing-sensitive tests that are flaky -# on macOS runners. The gevent/eventlet/asyncio reactor test files only contain -# those timing-sensitive timer tests, so they are not run separately here. +# on macOS runners. The asyncio reactor test file only contains those +# timing-sensitive timer tests, so it is not run separately here. test-command = [ - "CASS_DRIVER_NO_SKIP=1 pytest --import-mode=append {project}/tests/unit -v --ignore={project}/tests/unit/column_encryption --ignore={project}/tests/unit/io/test_geventreactor.py --ignore={project}/tests/unit/io/test_eventletreactor.py --ignore={project}/tests/unit/io/test_asyncioreactor.py --ignore={project}/tests/unit/io/test_asyncorereactor.py -k 'not (test_multi_timer_validation or test_empty_connections or test_timer_cancellation or test_deserialize_date_range_month)'", + "CASS_DRIVER_NO_SKIP=1 pytest --import-mode=append {project}/tests/unit -v --ignore={project}/tests/unit/column_encryption --ignore={project}/tests/unit/io/test_asyncioreactor.py --ignore={project}/tests/unit/io/test_asyncorereactor.py -k 'not (test_multi_timer_validation or test_empty_connections or test_timer_cancellation or test_deserialize_date_range_month)'", ] [tool.cibuildwheel.windows] From 2976ab747a109639c33057b2deed2fe9bc5695d8 Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Fri, 31 Jul 2026 22:32:55 -0400 Subject: [PATCH 136/200] test: track TcpProxy forwarders through socket cleanup --- tests/tcp_proxy.py | 14 +++- tests/unit/test_tcp_proxy.py | 125 ++++++++++++++++++++++++++++++++--- 2 files changed, 128 insertions(+), 11 deletions(-) diff --git a/tests/tcp_proxy.py b/tests/tcp_proxy.py index e87df3b430..16196bf3d2 100644 --- a/tests/tcp_proxy.py +++ b/tests/tcp_proxy.py @@ -51,6 +51,11 @@ def __init__(self, listen_host, listen_port, target_host, target_port): self._running = False self._thread = None self._lock = threading.Lock() + # Serializes manager-side shutdown with forwarder-owned close. Socket + # methods release the GIL around their syscalls, so the registry lock + # alone cannot prevent a descriptor from being closed and reused + # between shutdown()'s descriptor lookup and the syscall. + self._lifecycle_lock = threading.Lock() self._connections = {} # (client_sock, target_sock) -> forwarder thread self.total_connections = 0 @@ -112,7 +117,8 @@ def _shutdown_and_join_connections(self, stopping=False): self._running = False connections = list(self._connections.items()) for (csock, tsock), _thread in connections: - self._shutdown_pair(csock, tsock) + with self._lifecycle_lock: + self._shutdown_pair(csock, tsock) finished_keys = [] for (csock, tsock), thread in connections: thread.join(timeout=5) @@ -202,9 +208,13 @@ def _forward_loop(self, client_sock, target_sock): except (OSError, ConnectionResetError, BrokenPipeError): pass finally: + # Keep the connection registered until both sockets are closed, + # and serialize close with manager-side shutdown so neither can + # issue a syscall using a descriptor recycled by the other. + with self._lifecycle_lock: + self._close_pair(client_sock, target_sock) with self._lock: self._connections.pop((client_sock, target_sock), None) - self._close_pair(client_sock, target_sock) @staticmethod def _close_pair(csock, tsock): diff --git a/tests/unit/test_tcp_proxy.py b/tests/unit/test_tcp_proxy.py index 4c173c6576..cc7bef6e49 100644 --- a/tests/unit/test_tcp_proxy.py +++ b/tests/unit/test_tcp_proxy.py @@ -14,7 +14,7 @@ """ Regression tests for the ``TcpProxy`` test helper's connection -shutdown/join synchronization path (GitHub issue #948). +shutdown/join synchronization path (GitHub issues #948 and #962). ``TcpProxy`` lives in ``tests/tcp_proxy.py`` because it backs the Client Routes / NLB integration tests, but it is a plain socket-based helper with @@ -91,14 +91,10 @@ def _open_client(host, port, timeout=5): class TestTcpProxyShutdownJoin(unittest.TestCase): """ - Regression coverage for the forwarder-thread bookkeeping bug described - in issue #948: ``_shutdown_and_join_connections`` used to unconditionally - discard every tracked connection from ``_connections``, even ones whose - forwarder thread was still alive after ``thread.join(timeout=5)`` timed - out. That made ``active_connections`` under-report live connections and - made it impossible for a later ``stop()``/``drop_connections()`` call to - retry reaping an orphaned thread, permanently leaking the thread and its - file descriptors. + Regression coverage for the forwarder-thread bookkeeping bugs described + in issues #948 and #962. Connections must remain tracked both when a join + times out and while their sockets are being closed, so active connection + counts stay accurate and later shutdown calls can find live forwarders. """ def setUp(self): @@ -163,6 +159,117 @@ def test_timed_out_forwarder_thread_is_retained_until_it_exits(self): self.assertEqual(self.proxy.active_connections, 0) self.assertNotIn((csock, tsock), self.proxy._connections) + def test_forwarder_is_tracked_until_socket_cleanup_finishes(self): + """Regression for #962: keep connections tracked through socket cleanup.""" + client = _open_client(self.proxy.listen_host, self.proxy.listen_port) + self.addCleanup(client.close) + client.sendall(b"ping") + self.assertEqual(client.recv(16), b"ping") + + self.assertEqual(self.proxy.active_connections, 1) + connection, thread = next(iter(self.proxy._connections.items())) + cleanup_started = threading.Event() + allow_cleanup = threading.Event() + real_close_pair = TcpProxy._close_pair + + def blocking_close_pair(csock, tsock): + cleanup_started.set() + allow_cleanup.wait() + real_close_pair(csock, tsock) + + try: + with patch.object(TcpProxy, "_close_pair", + new=staticmethod(blocking_close_pair)): + client.shutdown(socket.SHUT_RDWR) + self.assertTrue( + cleanup_started.wait(timeout=5), + "forwarder did not begin socket cleanup") + + self.assertTrue(thread.is_alive()) + self.assertEqual(self.proxy.active_connections, 1) + self.assertIn(connection, self.proxy._connections) + finally: + allow_cleanup.set() + + thread.join(timeout=5) + self.assertFalse(thread.is_alive()) + self.assertEqual(self.proxy.active_connections, 0) + self.assertNotIn(connection, self.proxy._connections) + + def test_shutdown_is_serialized_with_socket_cleanup(self): + """Do not let shutdown race with the forwarder's final close.""" + client = _open_client(self.proxy.listen_host, self.proxy.listen_port) + self.addCleanup(client.close) + client.sendall(b"ping") + self.assertEqual(client.recv(16), b"ping") + + self.assertEqual(self.proxy.active_connections, 1) + _, forwarder = next(iter(self.proxy._connections.items())) + shutdown_started = threading.Event() + allow_shutdown = threading.Event() + close_lock_requested = threading.Event() + close_started = threading.Event() + dropper_errors = [] + real_shutdown_pair = TcpProxy._shutdown_pair + real_close_pair = TcpProxy._close_pair + + class ObservedLock: + def __init__(self): + self._lock = threading.Lock() + + def __enter__(self): + if threading.current_thread() is forwarder: + close_lock_requested.set() + self._lock.acquire() + return self + + def __exit__(self, *args): + self._lock.release() + + def blocking_shutdown_pair(csock, tsock): + shutdown_started.set() + allow_shutdown.wait() + real_shutdown_pair(csock, tsock) + + def recording_close_pair(csock, tsock): + close_started.set() + real_close_pair(csock, tsock) + + def drop_connections(): + try: + self.proxy.drop_connections() + except Exception as exc: + dropper_errors.append(exc) + + self.proxy._lifecycle_lock = ObservedLock() + dropper = threading.Thread(target=drop_connections) + try: + with patch.object(TcpProxy, "_shutdown_pair", + new=staticmethod(blocking_shutdown_pair)), \ + patch.object(TcpProxy, "_close_pair", + new=staticmethod(recording_close_pair)): + dropper.start() + self.assertTrue( + shutdown_started.wait(timeout=5), + "dropper did not begin socket shutdown") + + client.shutdown(socket.SHUT_RDWR) + self.assertTrue( + close_lock_requested.wait(timeout=5), + "forwarder did not attempt socket cleanup") + self.assertFalse( + close_started.is_set(), + "socket close overlapped an in-progress shutdown") + finally: + allow_shutdown.set() + dropper.join(timeout=5) + + self.assertFalse(dropper.is_alive()) + self.assertEqual(dropper_errors, []) + forwarder.join(timeout=5) + self.assertFalse(forwarder.is_alive()) + self.assertEqual(self.proxy.active_connections, 0) + def test_concurrent_stop_and_drop_leaves_no_live_forwarders(self): """ Deterministic stress regression test: concurrently open/close real From 811b163d1325bc58f24b4006127d53fecea0ba34 Mon Sep 17 00:00:00 2001 From: Luke Ratcliffe Date: Thu, 23 Apr 2026 08:46:51 +0000 Subject: [PATCH 137/200] Mark non-security md5 usage to allow for compatibility with fips environments patch by Luke Ratcliffe and Brad Schoening; reviewed by Bret McGuire and Brad Schoening --- cassandra/metadata.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cassandra/metadata.py b/cassandra/metadata.py index 326d046ecd..0cb17e1337 100644 --- a/cassandra/metadata.py +++ b/cassandra/metadata.py @@ -1968,7 +1968,7 @@ class MD5Token(HashToken): def hash_fn(cls, key): if isinstance(key, str): key = key.encode('UTF-8') - return abs(varint_unpack(md5(key).digest())) + return abs(varint_unpack(md5(key,usedforsecurity=False).digest())) class BytesToken(Token): From c534cf67afbcd1ff4736d2ba61f6332cef6da424 Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Wed, 8 Apr 2026 12:07:29 +0300 Subject: [PATCH 138/200] perf: add Cython LZ4 wrapper with direct C linkage for CQL v4 compression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement cython_lz4.pyx that calls LZ4_compress_default() and LZ4_decompress_safe() directly via Cython's cdef extern, bypassing the Python lz4 module's object allocation overhead in the hot compress/decompress path. Key design decisions: - Direct C linkage (cdef extern from "lz4.h") eliminates all intermediate Python object allocations for byte-order conversion - Compress path: LZ4_compress_default() writes into a temporary buffer (a fixed-size buffer on the C stack for the common case of small CQL frames, falling back to malloc/free for larger ones), then the exact-size result is copied once into a freshly allocated bytes object sized to the real compressed length - Wire-compatible with CQL binary protocol v4 format: [4 bytes big-endian uncompressed length][raw LZ4 compressed data] - Safety guards: LZ4_MAX_INPUT_SIZE check (prevents Py_ssize_t→int truncation), INT32_MAX compressed payload check, 256 MiB decompressed size cap, result size verification. The decompress path allocates the output bytes object to exactly the declared uncompressed size and calls LZ4_decompress_safe() (the bounds-checked variant) with that same size as dstCapacity, so a malformed/oversized compressed frame cannot write past the allocated buffer. - bytes not None parameter typing rejects None/bytearray/memoryview - PyPy-safe: this is a Cython module (CPython only); PyPy users automatically fall back to the pure-Python lz4 wrappers via the import chain in connection.py Integration: - connection.py: Cython import with fallback; also enables LZ4 without the Python lz4 package when the Cython extension is built - setup.py: separate Extension with libraries=['lz4'], excluded from the .pyx glob (which lacks the -llz4 link flag). On Windows this extension also links ws2_32 (htonl/ntohl come from winsock2.h there, same as cython_marshal.pyx, and live in ws2_32.lib rather than the lz4 import library), and on all platforms it reuses the same Homebrew/MacPorts include/library search paths already set up for libev, since Apple Silicon's /opt/homebrew prefix is not on the compiler's default search path. - cython_lz4.pyx: avoid alloca() for the small-frame fast path. alloca() lives in , which does not exist on Windows/MSVC (MSVC instead ships the differently-behaved _alloca() in ); a fixed-size buffer on the C stack gives the same performance without the platform-specific header. - CI: install lz4-devel on the Linux wheel-build image and lz4 via Homebrew on macOS (alongside the existing libev install steps) so the mandatory (CASS_DRIVER_BUILD_EXTENSIONS_ARE_MUST=yes) Linux/ macOS wheel builds can actually find lz4.h/liblz4 instead of failing outright. Windows wheel builds still treat the extension as optional and do not yet provision liblz4 there. Benchmark results (taskset -c 0, CPython 3.14): Payload Operation Python (ns) Cython (ns) Speedup 1KB compress 596 360 1.66x 1KB decompress 313 136 2.30x 8KB compress 1192 722 1.65x 8KB decompress 1102 825 1.34x 64KB compress 8179 3976 2.06x 64KB decompress 6539 4890 1.34x Co-Authored-By: Claude Sonnet 5 --- .github/workflows/lib-build.yml | 4 +- benchmarks/bench_lz4.py | 153 +++++++++++++++++++ cassandra/connection.py | 16 +- cassandra/cython_lz4.pyx | 214 +++++++++++++++++++++++++++ pyproject.toml | 2 +- setup.py | 27 ++++ tests/unit/cython/test_cython_lz4.py | 212 ++++++++++++++++++++++++++ 7 files changed, 623 insertions(+), 5 deletions(-) create mode 100644 benchmarks/bench_lz4.py create mode 100644 cassandra/cython_lz4.pyx create mode 100644 tests/unit/cython/test_cython_lz4.py diff --git a/.github/workflows/lib-build.yml b/.github/workflows/lib-build.yml index f6959ddfec..17505a5fd6 100644 --- a/.github/workflows/lib-build.yml +++ b/.github/workflows/lib-build.yml @@ -119,10 +119,10 @@ jobs: conan profile detect conan install conanfile.py - - name: Install libev for MacOS + - name: Install libev and lz4 for MacOS if: runner.os == 'MacOs' run: | - brew install libev + brew install libev lz4 - name: Overwrite for MacOS if: runner.os == 'MacOS' diff --git a/benchmarks/bench_lz4.py b/benchmarks/bench_lz4.py new file mode 100644 index 0000000000..d16df7d2ba --- /dev/null +++ b/benchmarks/bench_lz4.py @@ -0,0 +1,153 @@ +# Copyright 2026 ScyllaDB, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Microbenchmark comparing the Python lz4 wrappers (connection.py) against +the Cython direct-C-linkage wrappers (cython_lz4.pyx) for the CQL binary +protocol v4 LZ4 compression path. + +Usage (pin to one core for stable numbers): + + taskset -c 0 python benchmarks/bench_lz4.py + +Payload sizes tested: 1 KB, 8 KB, 64 KB. +""" + +import os +import struct +import timeit + +# --------------------------------------------------------------------------- +# Python wrappers (duplicated from connection.py to avoid import side-effects) +# --------------------------------------------------------------------------- +try: + import lz4.block as lz4_block + HAS_PYTHON_LZ4 = True +except ImportError: + HAS_PYTHON_LZ4 = False + print("WARNING: lz4 Python package not available, skipping Python benchmarks") + +int32_pack = struct.Struct('>i').pack + + +def py_lz4_compress(byts): + return int32_pack(len(byts)) + lz4_block.compress(byts)[4:] + + +def py_lz4_decompress(byts): + return lz4_block.decompress(byts[3::-1] + byts[4:]) + + +# --------------------------------------------------------------------------- +# Cython wrappers +# --------------------------------------------------------------------------- +try: + from cassandra.cython_lz4 import lz4_compress as cy_lz4_compress + from cassandra.cython_lz4 import lz4_decompress as cy_lz4_decompress + HAS_CYTHON = True +except ImportError: + HAS_CYTHON = False + print("WARNING: cassandra.cython_lz4 not available, skipping Cython benchmarks") + + +# --------------------------------------------------------------------------- +# Benchmark helpers +# --------------------------------------------------------------------------- +SIZES = { + "1KB": 1024, + "8KB": 8 * 1024, + "64KB": 64 * 1024, +} + +# Number of inner-loop iterations per timeit.repeat() call. +INNER = 10_000 +# Number of repetitions (we report the minimum). +REPEAT = 5 + + +def make_payload(size): + """Generate a pseudo-realistic compressible payload.""" + # Mix of repetitive and random-ish bytes to simulate CQL result rows. + chunk = (b"row_value_" + os.urandom(6)) * (size // 16 + 1) + return chunk[:size] + + +def bench(label, func, arg, inner=INNER, repeat=REPEAT): + """Return the best per-call time in nanoseconds.""" + times = timeit.repeat(lambda: func(arg), number=inner, repeat=repeat) + best = min(times) / inner + ns = best * 1e9 + return ns + + +def main(): + if not HAS_PYTHON_LZ4 and not HAS_CYTHON: + print("ERROR: Neither lz4 Python package nor cassandra.cython_lz4 available.") + return + + headers = f"{'Payload':<8} {'Operation':<12} " + if HAS_PYTHON_LZ4: + headers += f"{'Python (ns)':>12} " + if HAS_CYTHON: + headers += f"{'Cython (ns)':>12} " + if HAS_PYTHON_LZ4 and HAS_CYTHON: + headers += f"{'Speedup':>8}" + print(headers) + print("-" * len(headers)) + + for size_label, size in SIZES.items(): + payload = make_payload(size) + + # -- compress -- + py_compressed = None + cy_compressed = None + + row = f"{size_label:<8} {'compress':<12} " + if HAS_PYTHON_LZ4: + py_ns = bench("py_compress", py_lz4_compress, payload) + py_compressed = py_lz4_compress(payload) + row += f"{py_ns:>12.1f} " + if HAS_CYTHON: + cy_ns = bench("cy_compress", cy_lz4_compress, payload) + cy_compressed = cy_lz4_compress(payload) + row += f"{cy_ns:>12.1f} " + if HAS_PYTHON_LZ4: + speedup = py_ns / cy_ns if cy_ns > 0 else float('inf') + row += f"{speedup:>7.2f}x" + print(row) + + # Verify cross-compatibility: Cython can decompress Python's output + if HAS_PYTHON_LZ4 and HAS_CYTHON: + assert cy_lz4_decompress(py_compressed) == payload, "cross-compat failed (py->cy)" + assert py_lz4_decompress(cy_compressed) == payload, "cross-compat failed (cy->py)" + + # -- decompress -- + row = f"{size_label:<8} {'decompress':<12} " + if HAS_PYTHON_LZ4: + py_ns = bench("py_decompress", py_lz4_decompress, py_compressed) + row += f"{py_ns:>12.1f} " + if HAS_CYTHON: + # Use Cython-compressed data for the Cython decompress benchmark + cy_ns = bench("cy_decompress", cy_lz4_decompress, cy_compressed) + row += f"{cy_ns:>12.1f} " + if HAS_PYTHON_LZ4: + speedup = py_ns / cy_ns if cy_ns > 0 else float('inf') + row += f"{speedup:>7.2f}x" + print(row) + + print() + + +if __name__ == "__main__": + main() diff --git a/cassandra/connection.py b/cassandra/connection.py index 72a8d1b13c..9f62deda7c 100644 --- a/cassandra/connection.py +++ b/cassandra/connection.py @@ -62,8 +62,7 @@ try: import lz4 except ImportError: - log.debug("lz4 package could not be imported. LZ4 Compression will not be available") - pass + lz4 = None else: # The compress and decompress functions we need were moved from the lz4 to # the lz4.block namespace, so we try both here. @@ -98,6 +97,19 @@ def lz4_decompress(byts): locally_supported_compressions['lz4'] = (lz4_compress, lz4_decompress) segment_codec_lz4 = SegmentCodec(lz4_compress, lz4_decompress) +# Prefer the Cython wrappers that call liblz4 directly (no Python object +# allocation overhead for the byte-order conversion). This also enables +# LZ4 support when the Cython extension is available but the Python lz4 +# package is not installed. +try: + from cassandra.cython_lz4 import lz4_compress, lz4_decompress + locally_supported_compressions['lz4'] = (lz4_compress, lz4_decompress) + segment_codec_lz4 = SegmentCodec(lz4_compress, lz4_decompress) +except ImportError: + if lz4 is None: + log.debug("Neither the lz4 package nor the cython_lz4 extension could " + "be imported. LZ4 Compression will not be available") + try: import snappy except ImportError: diff --git a/cassandra/cython_lz4.pyx b/cassandra/cython_lz4.pyx new file mode 100644 index 0000000000..3367eafc47 --- /dev/null +++ b/cassandra/cython_lz4.pyx @@ -0,0 +1,214 @@ +# Copyright 2026 ScyllaDB, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Cython-optimized LZ4 compression/decompression wrappers that call the LZ4 C +library directly, bypassing the Python lz4 module's overhead. + +These functions produce output that is wire-compatible with the CQL binary +protocol v4 LZ4 compression format: + + [4 bytes big-endian uncompressed length] [LZ4 compressed data] + +The Cassandra/ScyllaDB protocol requires big-endian byte order for the +uncompressed length prefix, while the Python lz4 library uses little-endian. +The pure-Python wrappers in connection.py work around this mismatch by +byte-swapping and slicing Python bytes objects, which allocates intermediate +objects on every call. By calling LZ4_compress_default() and +LZ4_decompress_safe() through Cython's C interface we avoid all intermediate +Python object allocations and perform the byte-order conversion with simple +C pointer operations. +""" + +from cpython.bytes cimport (PyBytes_AS_STRING, PyBytes_GET_SIZE, + PyBytes_FromStringAndSize) +from libc.stdint cimport uint32_t, INT32_MAX +from libc.stdlib cimport malloc, free +from libc.string cimport memcpy + +# Use htonl/ntohl for big-endian ↔ native conversion (single bswap +# instruction on x86). Same cross-platform pattern used in +# cython_marshal.pyx (see PR #732). +cdef extern from *: + """ + #ifdef _WIN32 + #include + #else + #include + #endif + """ + uint32_t htonl(uint32_t hostlong) nogil + uint32_t ntohl(uint32_t netlong) nogil + +# CQL native protocol v4 frames have a 32-bit body length, so the +# theoretical maximum is ~2 GiB. We use 256 MiB as a practical upper +# bound (matching the server's default frame size limit) to avoid +# accidentally allocating multi-GiB buffers on corrupt headers. +DEF MAX_DECOMPRESSED_LENGTH = 268435456 # 256 MiB + +# LZ4_MAX_INPUT_SIZE from lz4.h — the LZ4 C API uses C int (32-bit +# signed) for sizes, so we must reject Python bytes objects that +# exceed this before casting Py_ssize_t down to int. +DEF LZ4_MAX_INPUT_SIZE = 0x7E000000 # 2 113 929 216 bytes + +# Maximum LZ4_compressBound value for which we use a fixed-size buffer on +# the C stack instead of malloc. 128 KiB is well within the default +# 8 MiB thread stack size (POSIX) / 1 MiB (Windows) and covers CQL frames +# up to ~127 KiB uncompressed — the vast majority of real traffic. Larger +# frames fall back to heap allocation. A plain fixed-size array is used +# (rather than alloca()) because alloca() is declared in , which +# does not exist on Windows/MSVC (it ships _alloca() in instead +# with subtly different semantics) — a fixed-size array avoids that +# platform split entirely while remaining just as cheap. +DEF STACK_ALLOC_THRESHOLD = 131072 # 128 KiB + + +cdef extern from "lz4.h": + int LZ4_compress_default(const char *src, char *dst, + int srcSize, int dstCapacity) nogil + int LZ4_decompress_safe(const char *src, char *dst, + int compressedSize, int dstCapacity) nogil + int LZ4_compressBound(int inputSize) nogil + + +cdef inline void _write_be32(char *dst, uint32_t value) noexcept nogil: + """Write a 32-bit unsigned integer in big-endian byte order.""" + cdef uint32_t tmp = htonl(value) + memcpy(dst, &tmp, 4) + + +cdef inline uint32_t _read_be32(const char *src) noexcept nogil: + """Read a 32-bit unsigned integer in big-endian byte order.""" + cdef uint32_t tmp + memcpy(&tmp, src, 4) + return ntohl(tmp) + + +def lz4_compress(bytes data not None): + """Compress *data* using LZ4 for the CQL binary protocol. + + Returns a bytes object containing a 4-byte big-endian uncompressed-length + header followed by the raw LZ4-compressed payload. + + Raises ``RuntimeError`` if LZ4 compression fails (returns 0). This should + only happen if *data* exceeds ``LZ4_MAX_INPUT_SIZE`` (~1.9 GiB). + """ + cdef Py_ssize_t src_len = PyBytes_GET_SIZE(data) + + if src_len > LZ4_MAX_INPUT_SIZE: + raise OverflowError( + "Input size %d exceeds LZ4_MAX_INPUT_SIZE (%d)" % + (src_len, LZ4_MAX_INPUT_SIZE)) + + cdef const char *src = PyBytes_AS_STRING(data) + cdef int src_size = src_len + + cdef int bound = LZ4_compressBound(src_size) + if bound <= 0: + raise RuntimeError( + "LZ4_compressBound() returned non-positive value for input " + "size %d; input may exceed LZ4_MAX_INPUT_SIZE" % src_size) + + # Compress into a temporary buffer to learn the exact output size, + # then copy into an exact-size Python bytes object. For typical CQL + # frames (bound <= 128 KiB) we use a fixed-size buffer on the C stack + # to avoid heap malloc/free overhead entirely. Rare oversized frames + # fall back to heap allocation. + cdef char stack_buf[STACK_ALLOC_THRESHOLD] + cdef char *tmp + cdef bint heap_allocated = bound > STACK_ALLOC_THRESHOLD + if heap_allocated: + tmp = malloc(bound) + if tmp == NULL: + raise MemoryError( + "Failed to allocate %d-byte LZ4 compression buffer" % bound) + else: + tmp = stack_buf + + cdef int compressed_size + cdef Py_ssize_t final_size + cdef bytes result + cdef char *out_ptr + try: + with nogil: + compressed_size = LZ4_compress_default(src, tmp, src_size, bound) + if compressed_size <= 0: + raise RuntimeError( + "LZ4_compress_default() failed for input size %d" % src_size) + + # Build the final bytes: [4-byte BE header][compressed data]. + final_size = 4 + compressed_size + result = PyBytes_FromStringAndSize(NULL, final_size) + out_ptr = PyBytes_AS_STRING(result) + _write_be32(out_ptr, src_size) + memcpy(out_ptr + 4, tmp, compressed_size) + return result + finally: + if heap_allocated: + free(tmp) + + +def lz4_decompress(bytes data not None): + """Decompress a CQL-protocol LZ4 frame. + + Expects *data* to start with a 4-byte big-endian uncompressed-length header + followed by raw LZ4-compressed payload (the format produced by + :func:`lz4_compress` and by the Cassandra/ScyllaDB server). + + Raises ``ValueError`` if *data* is too short or the declared size + exceeds the safety limit. Raises ``RuntimeError`` if decompression + fails (malformed payload, including a zero-length declared size with + a missing or invalid compressed block). + """ + cdef const char *src = PyBytes_AS_STRING(data) + cdef Py_ssize_t src_len = PyBytes_GET_SIZE(data) + + if src_len < 4: + raise ValueError( + "LZ4-compressed frame too short: need at least 4 bytes for the " + "length header, got %d" % src_len) + + cdef uint32_t uncompressed_size = _read_be32(src) + + if uncompressed_size > MAX_DECOMPRESSED_LENGTH: + raise ValueError( + "Declared uncompressed size %d exceeds safety limit of %d bytes; " + "frame header may be corrupt" % (uncompressed_size, + MAX_DECOMPRESSED_LENGTH)) + + cdef Py_ssize_t compressed_len = src_len - 4 + if compressed_len > INT32_MAX: + raise ValueError( + "Compressed payload size %d exceeds maximum supported size" % + compressed_len) + cdef int compressed_size = compressed_len + cdef bytes out = PyBytes_FromStringAndSize(NULL, uncompressed_size) + cdef char *out_ptr = PyBytes_AS_STRING(out) + + cdef int result + with nogil: + result = LZ4_decompress_safe(src + 4, out_ptr, + compressed_size, + uncompressed_size) + if result < 0: + raise RuntimeError( + "LZ4_decompress_safe() failed with error code %d; " + "compressed payload may be malformed" % result) + + if result != uncompressed_size: + raise RuntimeError( + "LZ4_decompress_safe() produced %d bytes but header declared %d" % + (result, uncompressed_size)) + + return out diff --git a/pyproject.toml b/pyproject.toml index 5270281d0c..61dfbf3b82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -154,7 +154,7 @@ manylinux-pypy_aarch64-image = "manylinux_2_28" enable = ["pypy"] [tool.cibuildwheel.linux] -before-build = "rm -rf ~/.pyxbld && rpm --import https://repo.almalinux.org/almalinux/RPM-GPG-KEY-AlmaLinux && yum install -y libffi-devel libev libev-devel openssl openssl-devel" +before-build = "rm -rf ~/.pyxbld && rpm --import https://repo.almalinux.org/almalinux/RPM-GPG-KEY-AlmaLinux && yum install -y libffi-devel libev libev-devel openssl openssl-devel lz4-devel" # Install the optional lz4 compression dependency so the lz4 segment tests run # (and fail loudly under CASS_DRIVER_NO_SKIP) instead of skipping silently. test-extras = ["compress-lz4"] diff --git a/setup.py b/setup.py index 52e04a63e5..52a043353a 100644 --- a/setup.py +++ b/setup.py @@ -340,9 +340,36 @@ def _setup_extensions(self): self.extensions.extend(cythonize( NoPatchExtension("*", ["cassandra/*.pyx"], extra_compile_args=compile_args), + exclude=["cassandra/cython_lz4.pyx"], nthreads=build_concurrency, compiler_directives={'language_level': 3}, )) + + # cython_lz4 needs to link against liblz4, so it gets its + # own Extension entry rather than riding the .pyx glob above. + # It also declares htonl/ntohl via winsock2.h on Windows (see + # cython_lz4.pyx), and those symbols live in ws2_32.lib, not + # in the lz4 import library — so on Windows it must link + # against both. + lz4_libraries = ['lz4', 'ws2_32'] if is_windows else ['lz4'] + # liblz4 is typically installed via the system package manager + # on Linux (standard /usr/include, /usr/lib64 already on the + # default search path) but on macOS Homebrew/MacPorts installs + # (especially the Apple Silicon /opt/homebrew prefix) are not + # on the compiler's default search path. Reuse the same + # Homebrew/MacPorts search paths already used for libev above + # -- lz4 installs its headers/libs in the same locations. + self.extensions.extend(cythonize( + Extension('cassandra.cython_lz4', + ['cassandra/cython_lz4.pyx'], + include_dirs=libev_includes, + libraries=lz4_libraries, + library_dirs=libev_libdirs, + extra_compile_args=compile_args), + nthreads=build_concurrency, + compiler_directives={'language_level': 3}, + exclude_failures=not CASS_DRIVER_BUILD_EXTENSIONS_ARE_MUST, + )) except Exception: sys.stderr.write("Failed to cythonize one or more modules. These will not be compiled as extensions (optional).\n") if CASS_DRIVER_BUILD_EXTENSIONS_ARE_MUST: diff --git a/tests/unit/cython/test_cython_lz4.py b/tests/unit/cython/test_cython_lz4.py new file mode 100644 index 0000000000..749f86e26b --- /dev/null +++ b/tests/unit/cython/test_cython_lz4.py @@ -0,0 +1,212 @@ +# Copyright 2026 ScyllaDB, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Unit tests for the Cython LZ4 direct-C-linkage wrappers. + +Tests verify: + - Round-trip correctness at various payload sizes + - Wire-format compatibility with the Python lz4 wrappers in connection.py + - Edge cases (empty input, minimal input, header-only frames) + - Error handling (truncated frames, corrupt payloads) +""" + +import os +import struct +import unittest + +try: + from cassandra.cython_lz4 import lz4_compress, lz4_decompress + HAS_CYTHON_LZ4 = True +except ImportError: + HAS_CYTHON_LZ4 = False + +try: + import lz4.block as lz4_block + HAS_PYTHON_LZ4 = True +except ImportError: + HAS_PYTHON_LZ4 = False + +int32_pack = struct.Struct('>i').pack + + +def _py_lz4_compress(byts): + """Python LZ4 compress wrapper (same logic as connection.py).""" + return int32_pack(len(byts)) + lz4_block.compress(byts)[4:] + + +def _py_lz4_decompress(byts): + """Python LZ4 decompress wrapper (same logic as connection.py).""" + return lz4_block.decompress(byts[3::-1] + byts[4:]) + + +@unittest.skipUnless(HAS_CYTHON_LZ4, "cassandra.cython_lz4 extension not available") +class CythonLZ4Test(unittest.TestCase): + """Tests for cassandra.cython_lz4.lz4_compress / lz4_decompress.""" + + def test_round_trip_small(self): + """Round-trip a small payload.""" + data = b"Hello, CQL!" * 10 + self.assertEqual(lz4_decompress(lz4_compress(data)), data) + + def test_round_trip_1kb(self): + data = os.urandom(512) + b"\x00" * 512 # 1 KB, partially compressible + self.assertEqual(lz4_decompress(lz4_compress(data)), data) + + def test_round_trip_8kb(self): + data = (b"row_data_" + os.urandom(7)) * 512 # ~8 KB + data = data[:8192] + self.assertEqual(lz4_decompress(lz4_compress(data)), data) + + def test_round_trip_64kb(self): + data = os.urandom(65536) + self.assertEqual(lz4_decompress(lz4_compress(data)), data) + + def test_round_trip_heap_buffer(self): + """Inputs above the 128 KiB stack threshold use the malloc path.""" + # LZ4_compressBound(131072) = 131072 + 131072/255 + 16 = 131602, + # which exceeds STACK_ALLOC_THRESHOLD, so lz4_compress must fall + # back to the heap buffer and free it on success. + data = os.urandom(131072) + self.assertEqual(lz4_decompress(lz4_compress(data)), data) + + def test_round_trip_empty(self): + """Empty input should round-trip to empty bytes.""" + compressed = lz4_compress(b"") + self.assertEqual(lz4_decompress(compressed), b"") + + def test_round_trip_single_byte(self): + data = b"\x42" + self.assertEqual(lz4_decompress(lz4_compress(data)), data) + + def test_compress_header_format(self): + """Verify the 4-byte big-endian uncompressed length header.""" + data = b"x" * 300 + compressed = lz4_compress(data) + # First 4 bytes should be big-endian length of original data + header = struct.unpack('>I', compressed[:4])[0] + self.assertEqual(header, 300) + + def test_decompress_too_short(self): + """Frames shorter than 4 bytes should raise ValueError.""" + with self.assertRaises(ValueError): + lz4_decompress(b"") + with self.assertRaises(ValueError): + lz4_decompress(b"\x00") + with self.assertRaises(ValueError): + lz4_decompress(b"\x00\x00\x00") + + def test_decompress_zero_length_header(self): + """Zero declared size requires a valid empty LZ4 block (one token byte).""" + # lz4_compress(b"") emits a zero BE length header + a single 0x00 + # token that is a valid empty LZ4 block. + self.assertEqual(lz4_decompress(b"\x00\x00\x00\x00\x00"), b"") + + def test_decompress_zero_length_header_missing_block(self): + """Zero declared size with no compressed block should raise.""" + with self.assertRaises(RuntimeError): + lz4_decompress(b"\x00\x00\x00\x00") + + def test_decompress_zero_length_header_invalid_block(self): + """Zero declared size with a non-empty block token should raise.""" + with self.assertRaises(RuntimeError): + lz4_decompress(b"\x00\x00\x00\x00\xff") + + def test_decompress_corrupt_payload(self): + """Corrupted compressed data should raise RuntimeError.""" + # Valid header claiming 1000 bytes, but garbage payload + bad_frame = struct.pack('>I', 1000) + b"\xff" * 20 + with self.assertRaises(RuntimeError): + lz4_decompress(bad_frame) + + def test_decompress_oversized_header(self): + """Header claiming > 256 MiB should raise ValueError.""" + # 0x10000001 = 256 MiB + 1 + huge_header = struct.pack('>I', 0x10000001) + b"\x00" * 10 + with self.assertRaises(ValueError): + lz4_decompress(huge_header) + + def test_round_trip_all_zeros(self): + """All-zero payloads compress extremely well; verify correctness.""" + data = b"\x00" * 10000 + self.assertEqual(lz4_decompress(lz4_compress(data)), data) + + def test_round_trip_all_ones(self): + data = b"\xff" * 10000 + self.assertEqual(lz4_decompress(lz4_compress(data)), data) + + def test_compress_rejects_none(self): + """None input should raise TypeError (enforced by Cython bytes type).""" + with self.assertRaises(TypeError): + lz4_compress(None) + + def test_decompress_rejects_none(self): + with self.assertRaises(TypeError): + lz4_decompress(None) + + def test_compress_rejects_non_bytes(self): + """bytearray and other non-bytes types should raise TypeError.""" + with self.assertRaises(TypeError): + lz4_compress(bytearray(b"hello")) + with self.assertRaises(TypeError): + lz4_compress(memoryview(b"hello")) + with self.assertRaises(TypeError): + lz4_compress("hello") + + def test_decompress_rejects_non_bytes(self): + with self.assertRaises(TypeError): + lz4_decompress(bytearray(b"\x00\x00\x00\x05hello")) + with self.assertRaises(TypeError): + lz4_decompress("hello") + + def test_decompress_header_only_nonzero(self): + """A 4-byte header claiming non-zero size with no payload should fail.""" + header_only = struct.pack('>I', 10) # claims 10 bytes, but no data + with self.assertRaises(RuntimeError): + lz4_decompress(header_only) + + +@unittest.skipUnless(HAS_CYTHON_LZ4 and HAS_PYTHON_LZ4, + "Both cassandra.cython_lz4 and lz4 package required") +class CythonLZ4CrossCompatTest(unittest.TestCase): + """Verify wire-format compatibility between Cython and Python wrappers.""" + + def _check_cross_compat(self, data): + """Assert both directions of cross-compatibility.""" + py_compressed = _py_lz4_compress(data) + cy_compressed = lz4_compress(data) + + # Cython decompresses Python's output + self.assertEqual(lz4_decompress(py_compressed), data) + # Python decompresses Cython's output + self.assertEqual(_py_lz4_decompress(cy_compressed), data) + + def test_cross_compat_small(self): + self._check_cross_compat(b"Hello, world!" * 50) + + def test_cross_compat_1kb(self): + self._check_cross_compat(os.urandom(1024)) + + def test_cross_compat_8kb(self): + self._check_cross_compat(os.urandom(8192)) + + def test_cross_compat_64kb(self): + self._check_cross_compat(os.urandom(65536)) + + def test_cross_compat_empty(self): + self._check_cross_compat(b"") + + +if __name__ == "__main__": + unittest.main() From cbf08b56a28e3dc9a72bc276b278ae7657c19eb3 Mon Sep 17 00:00:00 2001 From: Yaniv Kaul Date: Wed, 19 Aug 2026 20:54:15 +0300 Subject: [PATCH 139/200] Address LZ4 review feedback --- benchmarks/bench_lz4.py | 4 ++-- cassandra/cython_lz4.pyx | 21 ++++++++++----------- conanfile.py | 11 +++++++---- setup.py | 4 ++-- tests/unit/cython/test_cython_lz4.py | 17 ++++++++++++++--- 5 files changed, 35 insertions(+), 22 deletions(-) diff --git a/benchmarks/bench_lz4.py b/benchmarks/bench_lz4.py index d16df7d2ba..0ee4b50655 100644 --- a/benchmarks/bench_lz4.py +++ b/benchmarks/bench_lz4.py @@ -79,8 +79,8 @@ def py_lz4_decompress(byts): def make_payload(size): """Generate a pseudo-realistic compressible payload.""" # Mix of repetitive and random-ish bytes to simulate CQL result rows. - chunk = (b"row_value_" + os.urandom(6)) * (size // 16 + 1) - return chunk[:size] + blocks = [b"row_value_" + os.urandom(6) for _ in range(size // 16 + 1)] + return b"".join(blocks)[:size] def bench(label, func, arg, inner=INNER, repeat=REPEAT): diff --git a/cassandra/cython_lz4.pyx b/cassandra/cython_lz4.pyx index 3367eafc47..12bb8703ee 100644 --- a/cassandra/cython_lz4.pyx +++ b/cassandra/cython_lz4.pyx @@ -55,23 +55,22 @@ cdef extern from *: # theoretical maximum is ~2 GiB. We use 256 MiB as a practical upper # bound (matching the server's default frame size limit) to avoid # accidentally allocating multi-GiB buffers on corrupt headers. -DEF MAX_DECOMPRESSED_LENGTH = 268435456 # 256 MiB +cdef enum: + MAX_DECOMPRESSED_LENGTH = 268435456 # 256 MiB # LZ4_MAX_INPUT_SIZE from lz4.h — the LZ4 C API uses C int (32-bit # signed) for sizes, so we must reject Python bytes objects that # exceed this before casting Py_ssize_t down to int. -DEF LZ4_MAX_INPUT_SIZE = 0x7E000000 # 2 113 929 216 bytes +cdef enum: + LZ4_MAX_INPUT_SIZE = 0x7E000000 # 2 113 929 216 bytes # Maximum LZ4_compressBound value for which we use a fixed-size buffer on -# the C stack instead of malloc. 128 KiB is well within the default -# 8 MiB thread stack size (POSIX) / 1 MiB (Windows) and covers CQL frames -# up to ~127 KiB uncompressed — the vast majority of real traffic. Larger -# frames fall back to heap allocation. A plain fixed-size array is used -# (rather than alloca()) because alloca() is declared in , which -# does not exist on Windows/MSVC (it ships _alloca() in instead -# with subtly different semantics) — a fixed-size array avoids that -# platform split entirely while remaining just as cheap. -DEF STACK_ALLOC_THRESHOLD = 131072 # 128 KiB +# the C stack instead of malloc. 16 KiB covers common CQL frames while +# keeping the per-call stack frame small. Larger frames fall back to heap +# allocation. A plain fixed-size array is used instead of alloca(), which +# is not portable across Windows/MSVC and POSIX. +cdef enum: + STACK_ALLOC_THRESHOLD = 16384 # 16 KiB cdef extern from "lz4.h": diff --git a/conanfile.py b/conanfile.py index bc2b27c1c6..97584b58a1 100644 --- a/conanfile.py +++ b/conanfile.py @@ -26,7 +26,8 @@ def generate(self) -> None: build_req = self._conanfile.dependencies.build # tool_requires test_req = self._conanfile.dependencies.test - content_buffer = "" + include_dirs = [] + library_dirs = [] # Filter the build_requires not activated for any requirement dependencies = [tup for tup in list(host_req.items()) + list(build_req.items()) + list(test_req.items()) if not tup[0].build] @@ -37,9 +38,11 @@ def generate(self) -> None: continue include_dir = Path(dep.package_folder) / 'include' package_dir = Path(dep.package_folder) / 'lib' - content_buffer += json.dumps(dict(include_dirs=str(include_dir), library_dirs=str(package_dir))) + include_dirs.append(str(include_dir)) + library_dirs.append(str(package_dir)) - save(self._conanfile, CONAN_COMMANDLINE_FILENAME, content_buffer) + content = json.dumps(dict(include_dirs=include_dirs, library_dirs=library_dirs)) + save(self._conanfile, CONAN_COMMANDLINE_FILENAME, content) self._conanfile.output.info(f"Generated {CONAN_COMMANDLINE_FILENAME}") @@ -47,7 +50,7 @@ class python_driverConan(ConanFile): win_bash = False settings = "os", "compiler", "build_type", "arch" - requires = "libev/4.33" + requires = "libev/4.33", "lz4/1.9.4" def layout(self): basic_layout(self) diff --git a/setup.py b/setup.py index 52a043353a..dde1db1a2c 100644 --- a/setup.py +++ b/setup.py @@ -155,8 +155,8 @@ def eval_env_var_as_array(varname): conan_envfile = Path(__file__).parent / 'build-release/conan/conandeps.env' if conan_envfile.exists(): conan_paths = json.loads(conan_envfile.read_text()) - libev_includes.extend([conan_paths.get('include_dirs')]) - libev_libdirs.extend([conan_paths.get('library_dirs')]) + libev_includes.extend(conan_paths.get('include_dirs', [])) + libev_libdirs.extend(conan_paths.get('library_dirs', [])) libev_ext = Extension('cassandra.io.libevwrapper', sources=['cassandra/io/libevwrapper.c'], diff --git a/tests/unit/cython/test_cython_lz4.py b/tests/unit/cython/test_cython_lz4.py index 749f86e26b..7d0ab0488b 100644 --- a/tests/unit/cython/test_cython_lz4.py +++ b/tests/unit/cython/test_cython_lz4.py @@ -74,11 +74,11 @@ def test_round_trip_64kb(self): self.assertEqual(lz4_decompress(lz4_compress(data)), data) def test_round_trip_heap_buffer(self): - """Inputs above the 128 KiB stack threshold use the malloc path.""" - # LZ4_compressBound(131072) = 131072 + 131072/255 + 16 = 131602, + """Inputs above the 16 KiB stack threshold use the malloc path.""" + # LZ4_compressBound(16384) = 16384 + 16384/255 + 16 = 16464, # which exceeds STACK_ALLOC_THRESHOLD, so lz4_compress must fall # back to the heap buffer and free it on success. - data = os.urandom(131072) + data = os.urandom(16384) self.assertEqual(lz4_decompress(lz4_compress(data)), data) def test_round_trip_empty(self): @@ -207,6 +207,17 @@ def test_cross_compat_64kb(self): def test_cross_compat_empty(self): self._check_cross_compat(b"") + def test_connection_prefers_cython_codec(self): + """The connection layer selects the Cython codec when present.""" + from cassandra import connection + + self.assertIs(connection.locally_supported_compressions['lz4'][0], + lz4_compress) + self.assertIs(connection.locally_supported_compressions['lz4'][1], + lz4_decompress) + self.assertIs(connection.segment_codec_lz4.compressor, lz4_compress) + self.assertIs(connection.segment_codec_lz4.decompressor, lz4_decompress) + if __name__ == "__main__": unittest.main() From e0446be50af13e4c168b48b60d7c8b600e96e4ea Mon Sep 17 00:00:00 2001 From: absurdfarce Date: Tue, 14 Jul 2026 21:45:56 -0500 Subject: [PATCH 140/200] CASSPYTHON-24 Remove Insights support patch by Bret McGuire; reviewed by Bret McGuire and Brad Schoening --- cassandra/cluster.py | 77 +----- cassandra/datastax/insights/__init__.py | 13 - cassandra/datastax/insights/registry.py | 122 --------- cassandra/datastax/insights/reporter.py | 217 --------------- cassandra/datastax/insights/serializers.py | 219 ---------------- cassandra/datastax/insights/util.py | 75 ------ pyproject.toml | 1 - tests/integration/standard/test_cluster.py | 5 +- .../test_control_connection_query_fallback.py | 3 - tests/integration/standard/test_metrics.py | 4 +- tests/unit/advanced/test_insights.py | 248 ------------------ tests/unit/test_cluster.py | 2 - 12 files changed, 9 insertions(+), 977 deletions(-) delete mode 100644 cassandra/datastax/insights/__init__.py delete mode 100644 cassandra/datastax/insights/registry.py delete mode 100644 cassandra/datastax/insights/reporter.py delete mode 100644 cassandra/datastax/insights/serializers.py delete mode 100644 cassandra/datastax/insights/util.py delete mode 100644 tests/unit/advanced/test_insights.py diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 238a06ae08..808d5804f5 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -86,9 +86,6 @@ from cassandra.timestamps import MonotonicTimestampGenerator from cassandra.util import _resolve_contact_points_to_string_map, Version, maybe_add_timeout_to_query -from cassandra.datastax.insights.reporter import MonitorReporter -from cassandra.datastax.insights.util import version_supports_insights - from cassandra.datastax.graph import (graph_object_row_factory, GraphOptions, GraphSON1Serializer, GraphProtocol, GraphSON2Serializer, GraphStatement, SimpleGraphStatement, graph_graphson2_row_factory, graph_graphson3_row_factory, @@ -1023,34 +1020,6 @@ def default_retry_policy(self, policy): documentation for :meth:`Session.timestamp_generator`. """ - monitor_reporting_enabled = False - """ - A boolean indicating if monitor reporting, which sends gathered data to - Insights when running against DSE 6.8 and higher. - """ - - monitor_reporting_interval = 30 - """ - A boolean indicating if monitor reporting, which sends gathered data to - Insights when running against DSE 6.8 and higher. - """ - - client_id = None - """ - A UUID that uniquely identifies this Cluster object to Insights. This will - be generated automatically unless the user provides one. - """ - - application_name = '' - """ - A string identifying this application to Insights. - """ - - application_version = '' - """ - A string identifiying this application's version to Insights - """ - cloud = None """ A dict of the cloud configuration. Example:: @@ -1202,11 +1171,6 @@ def __init__(self, no_compact=False, ssl_context=None, endpoint_factory=None, - application_name=None, - application_version=None, - monitor_reporting_enabled=True, - monitor_reporting_interval=30, - client_id=None, cloud=None, scylla_cloud=None, shard_aware_options=None, @@ -1475,8 +1439,6 @@ def __init__(self, self.connect_timeout = connect_timeout self.prepare_on_all_hosts = prepare_on_all_hosts self.reprepare_on_up = reprepare_on_up - self.monitor_reporting_enabled = monitor_reporting_enabled - self.monitor_reporting_interval = monitor_reporting_interval self.shard_aware_options = ShardAwareOptions(opts=shard_aware_options) if (client_routes_config is not None @@ -1512,21 +1474,14 @@ def __init__(self, schema_metadata_enabled, token_metadata_enabled, schema_meta_page_size=schema_metadata_page_size) - if client_id is None: - self.client_id = uuid.uuid4() - if application_name is not None: - self.application_name = application_name - if application_version is not None: - self.application_version = application_version - def _resolve_hostnames(self): raw_contact_points = [] for cp in [cp for cp in self.contact_points if not isinstance(cp, EndPoint)]: raw_contact_points.append(cp if isinstance(cp, tuple) else (cp, self.port)) self.endpoints_resolved = [cp for cp in self.contact_points if isinstance(cp, EndPoint)] - self._endpoint_map_for_insights = {repr(ep): '{ip}:{port}'.format(ip=ep.address, port=ep.port) - for ep in self.endpoints_resolved} + endpoint_map = {repr(ep): '{ip}:{port}'.format(ip=ep.address, port=ep.port) + for ep in self.endpoints_resolved} strs_resolved_map = _resolve_contact_points_to_string_map(raw_contact_points) self.endpoints_resolved.extend(list(chain( *[ @@ -1535,14 +1490,14 @@ def _resolve_hostnames(self): ] ))) - self._endpoint_map_for_insights.update( + endpoint_map.update( {key: ['{ip}:{port}'.format(ip=ip, port=port) for ip, port in value] for key, value in strs_resolved_map.items() if value is not None} ) if self.contact_points and (not self.endpoints_resolved): # only want to raise here if the user specified CPs but resolution failed - raise UnresolvableContactPoints(self._endpoint_map_for_insights) + raise UnresolvableContactPoints(endpoint_map) def _create_thread_pool_executor(self, **kwargs): """ @@ -2386,7 +2341,6 @@ class Session(object): keyspace = None is_shutdown = False session_id = None - _monitor_reporter = None _row_factory = staticmethod(named_tuple_factory) @property @@ -2573,8 +2527,7 @@ def default_serial_consistency_level(self, cl): session_id = None """ - A UUID that uniquely identifies this Session to Insights. This will be - generated automatically. + A UUID that uniquely identifies this Session. This will be generated automatically. """ _lock = None @@ -2632,22 +2585,7 @@ def __init__(self, cluster, hosts, keyspace=None): raise Exception( "column_encryption_policy is temporary disabled, until https://github.com/scylladb/python-driver/issues/365 is sorted out") - if self.cluster.monitor_reporting_enabled: - cc_host = self.cluster.get_control_connection_host() - valid_insights_version = (cc_host and version_supports_insights(cc_host.dse_version)) - if valid_insights_version: - self._monitor_reporter = MonitorReporter( - interval_sec=self.cluster.monitor_reporting_interval, - session=self, - ) - else: - if cc_host: - log.debug('Not starting MonitorReporter thread for Insights; ' - 'not supported by server version {v} on ' - 'ControlConnection host {c}'.format(v=cc_host.release_version, c=cc_host)) - - log.debug('Started Session with client_id {} and session_id {}'.format(self.cluster.client_id, - self.session_id)) + log.debug('Started Session with session_id {}'.format(self.session_id)) def execute(self, query, parameters=None, timeout=_NOT_SET, trace=False, custom_payload=None, execution_profile=EXEC_PROFILE_DEFAULT, @@ -3292,9 +3230,6 @@ def shutdown(self): future.cancel() wait_futures(self._initial_connect_futures) - if self._monitor_reporter: - self._monitor_reporter.stop() - for pool in tuple(self._pools.values()): pool.shutdown() diff --git a/cassandra/datastax/insights/__init__.py b/cassandra/datastax/insights/__init__.py deleted file mode 100644 index 2c9ca172f8..0000000000 --- a/cassandra/datastax/insights/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright DataStax, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. diff --git a/cassandra/datastax/insights/registry.py b/cassandra/datastax/insights/registry.py deleted file mode 100644 index 03daebd86e..0000000000 --- a/cassandra/datastax/insights/registry.py +++ /dev/null @@ -1,122 +0,0 @@ -# Copyright DataStax, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from collections import OrderedDict -from warnings import warn - -from cassandra.datastax.insights.util import namespace - -_NOT_SET = object() - - -def _default_serializer_for_object(obj, policy): - # the insights server expects an 'options' dict for policy - # objects, but not for other objects - if policy: - return {'type': obj.__class__.__name__, - 'namespace': namespace(obj.__class__), - 'options': {}} - else: - return {'type': obj.__class__.__name__, - 'namespace': namespace(obj.__class__)} - - -class InsightsSerializerRegistry(object): - - initialized = False - - def __init__(self, mapping_dict=None): - mapping_dict = mapping_dict or {} - class_order = self._class_topological_sort(mapping_dict) - self._mapping_dict = OrderedDict( - ((cls, mapping_dict[cls]) for cls in class_order) - ) - - def serialize(self, obj, policy=False, default=_NOT_SET, cls=None): - try: - return self._get_serializer(cls if cls is not None else obj.__class__)(obj) - except Exception: - if default is _NOT_SET: - result = _default_serializer_for_object(obj, policy) - else: - result = default - - return result - - def _get_serializer(self, cls): - try: - return self._mapping_dict[cls] - except KeyError: - for registered_cls, serializer in self._mapping_dict.items(): - if issubclass(cls, registered_cls): - return self._mapping_dict[registered_cls] - raise ValueError - - def register(self, cls, serializer): - self._mapping_dict[cls] = serializer - self._mapping_dict = OrderedDict( - ((cls, self._mapping_dict[cls]) - for cls in self._class_topological_sort(self._mapping_dict)) - ) - - def register_serializer_for(self, cls): - """ - Parameterized registration helper decorator. Given a class `cls`, - produces a function that registers the decorated function as a - serializer for it. - """ - def decorator(serializer): - self.register(cls, serializer) - return serializer - - return decorator - - @staticmethod - def _class_topological_sort(classes): - """ - A simple topological sort for classes. Takes an iterable of class objects - and returns a list A of those classes, ordered such that A[X] is never a - superclass of A[Y] for X < Y. - - This is an inefficient sort, but that's ok because classes are infrequently - registered. It's more important that this be maintainable than fast. - - We can't use `.sort()` or `sorted()` with a custom `key` -- those assume - a total ordering, which we don't have. - """ - unsorted, sorted_ = list(classes), [] - while unsorted: - head, tail = unsorted[0], unsorted[1:] - - # if head has no subclasses remaining, it can safely go in the list - if not any(issubclass(x, head) for x in tail): - sorted_.append(head) - else: - # move to the back -- head has to wait until all its subclasses - # are sorted into the list - tail.append(head) - - unsorted = tail - - # check that sort is valid - for i, head in enumerate(sorted_): - for after_head_value in sorted_[(i + 1):]: - if issubclass(after_head_value, head): - warn('Sorting classes produced an invalid ordering.\n' - 'In: {classes}\n' - 'Out: {sorted_}'.format(classes=classes, sorted_=sorted_)) - return sorted_ - - -insights_registry = InsightsSerializerRegistry() diff --git a/cassandra/datastax/insights/reporter.py b/cassandra/datastax/insights/reporter.py deleted file mode 100644 index bd923d6036..0000000000 --- a/cassandra/datastax/insights/reporter.py +++ /dev/null @@ -1,217 +0,0 @@ -# Copyright DataStax, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from collections import Counter -import datetime -import json -import logging -import multiprocessing -import random -import platform -import socket -import ssl -import sys -from threading import Event, Thread -import time - -from cassandra.policies import HostDistance -from cassandra.util import ms_timestamp_from_datetime -from cassandra.datastax.insights.registry import insights_registry -from cassandra.datastax.insights.serializers import initialize_registry - -log = logging.getLogger(__name__) - - -class MonitorReporter(Thread): - - def __init__(self, interval_sec, session): - """ - takes an int indicating interval between requests, a function returning - the connection to be used, and the timeout per request - """ - # Thread is an old-style class so we can't super() - Thread.__init__(self, name='monitor_reporter') - - initialize_registry(insights_registry) - - self._interval, self._session = interval_sec, session - - self._shutdown_event = Event() - self.daemon = True - self.start() - - def run(self): - self._send_via_rpc(self._get_startup_data()) - - # introduce some jitter -- send up to 1/10 of _interval early - self._shutdown_event.wait(self._interval * random.uniform(.9, 1)) - - while not self._shutdown_event.is_set(): - start_time = time.time() - - self._send_via_rpc(self._get_status_data()) - - elapsed = time.time() - start_time - self._shutdown_event.wait(max(self._interval - elapsed, 0.01)) - - # TODO: redundant with ConnectionHeartbeat.ShutdownException - class ShutDownException(Exception): - pass - - def _send_via_rpc(self, data): - try: - self._session.execute( - "CALL InsightsRpc.reportInsight(%s)", (json.dumps(data),) - ) - log.debug('Insights RPC data: {}'.format(data)) - except Exception as e: - log.debug('Insights RPC send failed with {}'.format(e)) - log.debug('Insights RPC data: {}'.format(data)) - - def _get_status_data(self): - cc = self._session.cluster.control_connection - - connected_nodes = { - host.address: { - 'connections': state['open_count'], - 'inFlightQueries': state['in_flights'] - } - for (host, state) in self._session.get_pool_state().items() - } - - return { - 'metadata': { - # shared across drivers; never change - 'name': 'driver.status', - # format version - 'insightMappingId': 'v1', - 'insightType': 'EVENT', - # since epoch - 'timestamp': ms_timestamp_from_datetime(datetime.datetime.utcnow()), - 'tags': { - 'language': 'python' - } - }, - # // 'clientId', 'sessionId' and 'controlConnection' are mandatory - # // the rest of the properties are optional - 'data': { - # // 'clientId' must be the same as the one provided in the startup message - 'clientId': str(self._session.cluster.client_id), - # // 'sessionId' must be the same as the one provided in the startup message - 'sessionId': str(self._session.session_id), - 'controlConnection': cc._connection.host if cc._connection else None, - 'connectedNodes': connected_nodes - } - } - - def _get_startup_data(self): - cc = self._session.cluster.control_connection - try: - local_ipaddr = cc._connection._socket.getsockname()[0] - except Exception as e: - local_ipaddr = None - log.debug('Unable to get local socket addr from {}: {}'.format(cc._connection, e)) - hostname = socket.getfqdn() - - host_distances_counter = Counter( - self._session.cluster.profile_manager.distance(host) - for host in self._session.hosts - ) - host_distances_dict = { - 'local': host_distances_counter[HostDistance.LOCAL], - 'remote': host_distances_counter[HostDistance.REMOTE], - 'ignored': host_distances_counter[HostDistance.IGNORED] - } - - try: - compression_type = cc._connection._compression_type - except AttributeError: - compression_type = 'NONE' - - cert_validation = None - try: - if self._session.cluster.ssl_context: - cert_validation = self._session.cluster.ssl_context.verify_mode == ssl.CERT_REQUIRED - elif self._session.cluster.ssl_options: - cert_validation = self._session.cluster.ssl_options.get('cert_reqs') == ssl.CERT_REQUIRED - except Exception as e: - log.debug('Unable to get the cert validation: {}'.format(e)) - - uname_info = platform.uname() - - return { - 'metadata': { - 'name': 'driver.startup', - 'insightMappingId': 'v1', - 'insightType': 'EVENT', - 'timestamp': ms_timestamp_from_datetime(datetime.datetime.utcnow()), - 'tags': { - 'language': 'python' - }, - }, - 'data': { - 'driverName': 'DataStax Python Driver', - 'driverVersion': sys.modules['cassandra'].__version__, - 'clientId': str(self._session.cluster.client_id), - 'sessionId': str(self._session.session_id), - 'applicationName': self._session.cluster.application_name or 'python', - 'applicationNameWasGenerated': not self._session.cluster.application_name, - 'applicationVersion': self._session.cluster.application_version, - 'contactPoints': self._session.cluster._endpoint_map_for_insights, - 'dataCenters': list(set(h.datacenter for h in self._session.cluster.metadata.all_hosts() - if (h.datacenter and - self._session.cluster.profile_manager.distance(h) == HostDistance.LOCAL))), - 'initialControlConnection': cc._connection.host if cc._connection else None, - 'protocolVersion': self._session.cluster.protocol_version, - 'localAddress': local_ipaddr, - 'hostName': hostname, - 'executionProfiles': insights_registry.serialize(self._session.cluster.profile_manager), - 'configuredConnectionLength': host_distances_dict, - 'heartbeatInterval': self._session.cluster.idle_heartbeat_interval, - 'compression': compression_type.upper() if compression_type else 'NONE', - 'reconnectionPolicy': insights_registry.serialize(self._session.cluster.reconnection_policy), - 'sslConfigured': { - 'enabled': bool(self._session.cluster.ssl_options or self._session.cluster.ssl_context), - 'certValidation': cert_validation - }, - 'authProvider': { - 'type': (self._session.cluster.auth_provider.__class__.__name__ - if self._session.cluster.auth_provider else - None) - }, - 'otherOptions': { - }, - 'platformInfo': { - 'os': { - 'name': uname_info.system, - 'version': uname_info.release, - 'arch': uname_info.machine - }, - 'cpus': { - 'length': multiprocessing.cpu_count(), - 'model': platform.processor() - }, - 'runtime': { - 'python': sys.version, - 'event_loop': self._session.cluster.connection_class.__name__ - } - }, - 'periodicStatusInterval': self._interval - } - } - - def stop(self): - log.debug("Shutting down Monitor Reporter") - self._shutdown_event.set() - self.join() diff --git a/cassandra/datastax/insights/serializers.py b/cassandra/datastax/insights/serializers.py deleted file mode 100644 index 289c165e8a..0000000000 --- a/cassandra/datastax/insights/serializers.py +++ /dev/null @@ -1,219 +0,0 @@ -# Copyright DataStax, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -def initialize_registry(insights_registry): - # This will be called from the cluster module, so we put all this behavior - # in a function to avoid circular imports - - if insights_registry.initialized: - return False - - from cassandra import ConsistencyLevel - from cassandra.cluster import ( - ExecutionProfile, GraphExecutionProfile, - ProfileManager, ContinuousPagingOptions, - EXEC_PROFILE_DEFAULT, EXEC_PROFILE_GRAPH_DEFAULT, - EXEC_PROFILE_GRAPH_ANALYTICS_DEFAULT, - EXEC_PROFILE_GRAPH_SYSTEM_DEFAULT, - _NOT_SET - ) - from cassandra.datastax.graph import GraphOptions - from cassandra.datastax.insights.registry import insights_registry - from cassandra.datastax.insights.util import namespace - from cassandra.policies import ( - RoundRobinPolicy, - DCAwareRoundRobinPolicy, - TokenAwarePolicy, - WhiteListRoundRobinPolicy, - HostFilterPolicy, - ConstantReconnectionPolicy, - ExponentialReconnectionPolicy, - RetryPolicy, - SpeculativeExecutionPolicy, - ConstantSpeculativeExecutionPolicy, - WrapperPolicy - ) - - import logging - - log = logging.getLogger(__name__) - - @insights_registry.register_serializer_for(RoundRobinPolicy) - def round_robin_policy_insights_serializer(policy): - return {'type': policy.__class__.__name__, - 'namespace': namespace(policy.__class__), - 'options': {}} - - @insights_registry.register_serializer_for(DCAwareRoundRobinPolicy) - def dc_aware_round_robin_policy_insights_serializer(policy): - return {'type': policy.__class__.__name__, - 'namespace': namespace(policy.__class__), - 'options': {'local_dc': policy.local_dc, - 'used_hosts_per_remote_dc': policy.used_hosts_per_remote_dc} - } - - @insights_registry.register_serializer_for(TokenAwarePolicy) - def token_aware_policy_insights_serializer(policy): - return {'type': policy.__class__.__name__, - 'namespace': namespace(policy.__class__), - 'options': {'child_policy': insights_registry.serialize(policy._child_policy, - policy=True), - 'shuffle_replicas': policy.shuffle_replicas} - } - - @insights_registry.register_serializer_for(WhiteListRoundRobinPolicy) - def whitelist_round_robin_policy_insights_serializer(policy): - return {'type': policy.__class__.__name__, - 'namespace': namespace(policy.__class__), - 'options': {'allowed_hosts': policy._allowed_hosts} - } - - @insights_registry.register_serializer_for(HostFilterPolicy) - def host_filter_policy_insights_serializer(policy): - return { - 'type': policy.__class__.__name__, - 'namespace': namespace(policy.__class__), - 'options': {'child_policy': insights_registry.serialize(policy._child_policy, - policy=True), - 'predicate': policy.predicate.__name__} - } - - @insights_registry.register_serializer_for(ConstantReconnectionPolicy) - def constant_reconnection_policy_insights_serializer(policy): - return {'type': policy.__class__.__name__, - 'namespace': namespace(policy.__class__), - 'options': {'delay': policy.delay, - 'max_attempts': policy.max_attempts} - } - - @insights_registry.register_serializer_for(ExponentialReconnectionPolicy) - def exponential_reconnection_policy_insights_serializer(policy): - return {'type': policy.__class__.__name__, - 'namespace': namespace(policy.__class__), - 'options': {'base_delay': policy.base_delay, - 'max_delay': policy.max_delay, - 'max_attempts': policy.max_attempts} - } - - @insights_registry.register_serializer_for(RetryPolicy) - def retry_policy_insights_serializer(policy): - return {'type': policy.__class__.__name__, - 'namespace': namespace(policy.__class__), - 'options': {}} - - @insights_registry.register_serializer_for(SpeculativeExecutionPolicy) - def speculative_execution_policy_insights_serializer(policy): - return {'type': policy.__class__.__name__, - 'namespace': namespace(policy.__class__), - 'options': {}} - - @insights_registry.register_serializer_for(ConstantSpeculativeExecutionPolicy) - def constant_speculative_execution_policy_insights_serializer(policy): - return {'type': policy.__class__.__name__, - 'namespace': namespace(policy.__class__), - 'options': {'delay': policy.delay, - 'max_attempts': policy.max_attempts} - } - - @insights_registry.register_serializer_for(WrapperPolicy) - def wrapper_policy_insights_serializer(policy): - return {'type': policy.__class__.__name__, - 'namespace': namespace(policy.__class__), - 'options': { - 'child_policy': insights_registry.serialize(policy._child_policy, - policy=True) - }} - - @insights_registry.register_serializer_for(ExecutionProfile) - def execution_profile_insights_serializer(profile): - return { - 'loadBalancing': insights_registry.serialize(profile.load_balancing_policy, - policy=True), - 'retry': insights_registry.serialize(profile.retry_policy, - policy=True), - 'readTimeout': profile.request_timeout, - 'consistency': ConsistencyLevel.value_to_name.get(profile.consistency_level, None), - 'serialConsistency': ConsistencyLevel.value_to_name.get(profile.serial_consistency_level, None), - 'continuousPagingOptions': (insights_registry.serialize(profile.continuous_paging_options) - if (profile.continuous_paging_options is not None and - profile.continuous_paging_options is not _NOT_SET) else - None), - 'speculativeExecution': insights_registry.serialize(profile.speculative_execution_policy), - 'graphOptions': None - } - - @insights_registry.register_serializer_for(GraphExecutionProfile) - def graph_execution_profile_insights_serializer(profile): - rv = insights_registry.serialize(profile, cls=ExecutionProfile) - rv['graphOptions'] = insights_registry.serialize(profile.graph_options) - return rv - - _EXEC_PROFILE_DEFAULT_KEYS = (EXEC_PROFILE_DEFAULT, - EXEC_PROFILE_GRAPH_DEFAULT, - EXEC_PROFILE_GRAPH_SYSTEM_DEFAULT, - EXEC_PROFILE_GRAPH_ANALYTICS_DEFAULT) - - @insights_registry.register_serializer_for(ProfileManager) - def profile_manager_insights_serializer(manager): - defaults = { - # Insights's expected default - 'default': insights_registry.serialize(manager.profiles[EXEC_PROFILE_DEFAULT]), - # remaining named defaults for driver's defaults, including duplicated default - 'EXEC_PROFILE_DEFAULT': insights_registry.serialize(manager.profiles[EXEC_PROFILE_DEFAULT]), - 'EXEC_PROFILE_GRAPH_DEFAULT': insights_registry.serialize(manager.profiles[EXEC_PROFILE_GRAPH_DEFAULT]), - 'EXEC_PROFILE_GRAPH_SYSTEM_DEFAULT': insights_registry.serialize( - manager.profiles[EXEC_PROFILE_GRAPH_SYSTEM_DEFAULT] - ), - 'EXEC_PROFILE_GRAPH_ANALYTICS_DEFAULT': insights_registry.serialize( - manager.profiles[EXEC_PROFILE_GRAPH_ANALYTICS_DEFAULT] - ) - } - other = { - key: insights_registry.serialize(value) - for key, value in manager.profiles.items() - if key not in _EXEC_PROFILE_DEFAULT_KEYS - } - overlapping_keys = set(defaults) & set(other) - if overlapping_keys: - log.debug('The following key names overlap default key sentinel keys ' - 'and these non-default EPs will not be displayed in Insights ' - ': {}'.format(list(overlapping_keys))) - - other.update(defaults) - return other - - @insights_registry.register_serializer_for(GraphOptions) - def graph_options_insights_serializer(options): - rv = { - 'source': options.graph_source, - 'language': options.graph_language, - 'graphProtocol': options.graph_protocol - } - updates = {k: v.decode('utf-8') for k, v in rv.items() - if isinstance(v, bytes)} - rv.update(updates) - return rv - - @insights_registry.register_serializer_for(ContinuousPagingOptions) - def continuous_paging_options_insights_serializer(paging_options): - return { - 'page_unit': paging_options.page_unit, - 'max_pages': paging_options.max_pages, - 'max_pages_per_second': paging_options.max_pages_per_second, - 'max_queue_size': paging_options.max_queue_size - } - - insights_registry.initialized = True - return True diff --git a/cassandra/datastax/insights/util.py b/cassandra/datastax/insights/util.py deleted file mode 100644 index a483b3f64d..0000000000 --- a/cassandra/datastax/insights/util.py +++ /dev/null @@ -1,75 +0,0 @@ -# Copyright DataStax, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import logging -import traceback -from warnings import warn - -from cassandra.util import Version - - -DSE_60 = Version('6.0.0') -DSE_51_MIN_SUPPORTED = Version('5.1.13') -DSE_60_MIN_SUPPORTED = Version('6.0.5') - - -log = logging.getLogger(__name__) - - -def namespace(cls): - """ - Best-effort method for getting the namespace in which a class is defined. - """ - try: - # __module__ can be None - module = cls.__module__ or '' - except Exception: - warn("Unable to obtain namespace for {cls} for Insights, returning ''. " - "Exception: \n{e}".format(e=traceback.format_exc(), cls=cls)) - module = '' - - module_internal_namespace = _module_internal_namespace_or_emtpy_string(cls) - if module_internal_namespace: - return '.'.join((module, module_internal_namespace)) - return module - - -def _module_internal_namespace_or_emtpy_string(cls): - """ - Best-effort method for getting the module-internal namespace in which a - class is defined -- i.e. the namespace _inside_ the module. - """ - try: - qualname = cls.__qualname__ - except AttributeError: - return '' - - return '.'.join( - # the last segment is the name of the class -- use everything else - qualname.split('.')[:-1] - ) - - -def version_supports_insights(dse_version): - if dse_version: - try: - dse_version = Version(dse_version) - return (DSE_51_MIN_SUPPORTED <= dse_version < DSE_60 - or - DSE_60_MIN_SUPPORTED <= dse_version) - except Exception: - warn("Unable to check version {v} for Insights compatibility, returning False. " - "Exception: \n{e}".format(e=traceback.format_exc(), v=dse_version)) - - return False diff --git a/pyproject.toml b/pyproject.toml index 61dfbf3b82..c0979ea097 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,6 @@ packages = [ 'cassandra.cqlengine', 'cassandra.graph', 'cassandra.datastax', - 'cassandra.datastax.insights', 'cassandra.datastax.graph', 'cassandra.datastax.graph.fluent', 'cassandra.datastax.cloud', diff --git a/tests/integration/standard/test_cluster.py b/tests/integration/standard/test_cluster.py index 9db4fede9e..e4e3a8f5e1 100644 --- a/tests/integration/standard/test_cluster.py +++ b/tests/integration/standard/test_cluster.py @@ -759,8 +759,7 @@ def _wait_for_all_shard_connections(self, cluster, timeout=30): def test_idle_heartbeat(self): interval = 2 - cluster = TestCluster(idle_heartbeat_interval=interval, - monitor_reporting_enabled=False) + cluster = TestCluster(idle_heartbeat_interval=interval) session = cluster.connect(wait_for_all_pools=True) # wait_for_all_pools only waits for the first connection per host; @@ -890,7 +889,7 @@ def test_profile_load_balancing(self): RoundRobinPolicy(), lambda host: host.address == CASSANDRA_IP ) ) - with TestCluster(execution_profiles={'node1': node1}, monitor_reporting_enabled=False) as cluster: + with TestCluster(execution_profiles={'node1': node1}) as cluster: session = cluster.connect(wait_for_all_pools=True) # default is DCA RR for all hosts diff --git a/tests/integration/standard/test_control_connection_query_fallback.py b/tests/integration/standard/test_control_connection_query_fallback.py index a9154f681e..b5481f15bc 100644 --- a/tests/integration/standard/test_control_connection_query_fallback.py +++ b/tests/integration/standard/test_control_connection_query_fallback.py @@ -62,7 +62,6 @@ def test_disabled_raises_when_broadcast_rpc_address_is_unreachable(self): self.cluster = TestCluster( allow_control_connection_query_fallback=ControlConnectionQueryFallback.Disabled, connect_timeout=1, - monitor_reporting_enabled=False, ) with pytest.raises(NoHostAvailable): @@ -76,7 +75,6 @@ def test_fallback_executes_queries_when_broadcast_rpc_address_is_unreachable(sel self.cluster = TestCluster( allow_control_connection_query_fallback=ControlConnectionQueryFallback.Fallback, connect_timeout=1, - monitor_reporting_enabled=False, ) session = self.cluster.connect() @@ -94,7 +92,6 @@ def test_no_node_pool_fallback_executes_queries_without_creating_pools(self): self.cluster = TestCluster( allow_control_connection_query_fallback=ControlConnectionQueryFallback.SkipPoolCreation, connect_timeout=1, - monitor_reporting_enabled=False, ) session = self.cluster.connect() diff --git a/tests/integration/standard/test_metrics.py b/tests/integration/standard/test_metrics.py index 7ebdded141..dc73929eb6 100644 --- a/tests/integration/standard/test_metrics.py +++ b/tests/integration/standard/test_metrics.py @@ -265,13 +265,11 @@ def test_duplicate_metrics_per_cluster(self): """ cluster2 = TestCluster( metrics_enabled=True, - monitor_reporting_enabled=False, execution_profiles={EXEC_PROFILE_DEFAULT: ExecutionProfile(retry_policy=FallthroughRetryPolicy())} ) cluster3 = TestCluster( metrics_enabled=True, - monitor_reporting_enabled=False, execution_profiles={EXEC_PROFILE_DEFAULT: ExecutionProfile(retry_policy=FallthroughRetryPolicy())} ) @@ -358,7 +356,7 @@ class MetricsRequestSize(BasicExistingKeyspaceUnitTestCase): @classmethod def setUpClass(cls): - cls.common_setup(1, keyspace_creation=False, monitor_reporting_enabled=False) + cls.common_setup(1, keyspace_creation=False) def wait_for_count(self, ra, expected_count, error=False): for _ in range(10): diff --git a/tests/unit/advanced/test_insights.py b/tests/unit/advanced/test_insights.py deleted file mode 100644 index 2050439804..0000000000 --- a/tests/unit/advanced/test_insights.py +++ /dev/null @@ -1,248 +0,0 @@ -# Copyright DataStax, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -import unittest - -import logging -import sys -from unittest.mock import sentinel - -from cassandra import ConsistencyLevel -from cassandra.cluster import ( - ExecutionProfile, GraphExecutionProfile, GraphAnalyticsExecutionProfile -) -from cassandra.datastax.graph.query import GraphOptions -from cassandra.datastax.insights.registry import insights_registry -from cassandra.datastax.insights.serializers import initialize_registry -from cassandra.policies import ( - LoadBalancingPolicy, - DCAwareRoundRobinPolicy, - TokenAwarePolicy, - WhiteListRoundRobinPolicy, - HostFilterPolicy, - ConstantReconnectionPolicy, - ExponentialReconnectionPolicy, - RetryPolicy, - SpeculativeExecutionPolicy, - ConstantSpeculativeExecutionPolicy, - WrapperPolicy -) - - -log = logging.getLogger(__name__) - -initialize_registry(insights_registry) - - -class TestGetConfig(unittest.TestCase): - - def test_invalid_object(self): - class NoConfAsDict(object): - pass - - obj = NoConfAsDict() - - ns = 'tests.unit.advanced.test_insights' - if sys.version_info > (3,): - ns += '.TestGetConfig.test_invalid_object.' - - # no default - # ... as a policy - assert insights_registry.serialize(obj, policy=True) == {'type': 'NoConfAsDict', - 'namespace': ns, - 'options': {}} - # ... not as a policy (default) - assert insights_registry.serialize(obj) == {'type': 'NoConfAsDict', - 'namespace': ns, - } - # with default - assert insights_registry.serialize(obj, default=sentinel.attr_err_default) is sentinel.attr_err_default - - def test_successful_return(self): - - class SuperclassSentinel(object): - pass - - class SubclassSentinel(SuperclassSentinel): - pass - - @insights_registry.register_serializer_for(SuperclassSentinel) - def superclass_sentinel_serializer(obj): - return sentinel.serialized_superclass - - assert insights_registry.serialize(SuperclassSentinel()) is sentinel.serialized_superclass - assert insights_registry.serialize(SubclassSentinel()) is sentinel.serialized_superclass - - # with default -- same behavior - assert insights_registry.serialize(SubclassSentinel(), default=object()) is sentinel.serialized_superclass - -class TestConfigAsDict(unittest.TestCase): - - # graph/query.py - def test_graph_options(self): - self.maxDiff = None - - go = GraphOptions(graph_name='name_for_test', - graph_source='source_for_test', - graph_language='lang_for_test', - graph_protocol='protocol_for_test', - graph_read_consistency_level=ConsistencyLevel.ANY, - graph_write_consistency_level=ConsistencyLevel.ONE, - graph_invalid_option='invalid') - - log.debug(go._graph_options) - - assert insights_registry.serialize(go) == {'source': 'source_for_test', - 'language': 'lang_for_test', - 'graphProtocol': 'protocol_for_test', - # no graph_invalid_option - } - - # cluster.py - def test_execution_profile(self): - self.maxDiff = None - assert insights_registry.serialize(ExecutionProfile()) == {'consistency': 'LOCAL_ONE', - 'continuousPagingOptions': None, - 'loadBalancing': {'namespace': 'cassandra.policies', - 'options': {'child_policy': {'namespace': 'cassandra.policies', - 'options': {'local_dc': '', - 'used_hosts_per_remote_dc': 0}, - 'type': 'DCAwareRoundRobinPolicy'}, - 'shuffle_replicas': True}, - 'type': 'TokenAwarePolicy'}, - 'readTimeout': 10.0, - 'retry': {'namespace': 'cassandra.policies', 'options': {}, 'type': 'RetryPolicy'}, - 'serialConsistency': None, - 'speculativeExecution': {'namespace': 'cassandra.policies', - 'options': {}, 'type': 'NoSpeculativeExecutionPolicy'}, - 'graphOptions': None - } - - def test_graph_execution_profile(self): - self.maxDiff = None - assert insights_registry.serialize(GraphExecutionProfile()) == {'consistency': 'LOCAL_ONE', - 'continuousPagingOptions': None, - 'loadBalancing': {'namespace': 'cassandra.policies', - 'options': {'child_policy': {'namespace': 'cassandra.policies', - 'options': {'local_dc': '', - 'used_hosts_per_remote_dc': 0}, - 'type': 'DCAwareRoundRobinPolicy'}, - 'shuffle_replicas': True}, - 'type': 'TokenAwarePolicy'}, - 'readTimeout': 30.0, - 'retry': {'namespace': 'cassandra.policies', 'options': {}, 'type': 'NeverRetryPolicy'}, - 'serialConsistency': None, - 'speculativeExecution': {'namespace': 'cassandra.policies', - 'options': {}, 'type': 'NoSpeculativeExecutionPolicy'}, - 'graphOptions': {'graphProtocol': None, - 'language': 'gremlin-groovy', - 'source': 'g'}, - } - - def test_graph_analytics_execution_profile(self): - self.maxDiff = None - assert insights_registry.serialize(GraphAnalyticsExecutionProfile()) == {'consistency': 'LOCAL_ONE', - 'continuousPagingOptions': None, - 'loadBalancing': {'namespace': 'cassandra.policies', - 'options': {'child_policy': {'namespace': 'cassandra.policies', - 'options': {'child_policy': {'namespace': 'cassandra.policies', - 'options': {'local_dc': '', - 'used_hosts_per_remote_dc': 0}, - 'type': 'DCAwareRoundRobinPolicy'}, - 'shuffle_replicas': True}, - 'type': 'TokenAwarePolicy'}}, - 'type': 'DefaultLoadBalancingPolicy'}, - 'readTimeout': 604800.0, - 'retry': {'namespace': 'cassandra.policies', 'options': {}, 'type': 'NeverRetryPolicy'}, - 'serialConsistency': None, - 'speculativeExecution': {'namespace': 'cassandra.policies', - 'options': {}, 'type': 'NoSpeculativeExecutionPolicy'}, - 'graphOptions': {'graphProtocol': None, - 'language': 'gremlin-groovy', - 'source': 'a'}, - } - - # policies.py - def test_DC_aware_round_robin_policy(self): - assert insights_registry.serialize(DCAwareRoundRobinPolicy()) == {'namespace': 'cassandra.policies', - 'options': {'local_dc': '', 'used_hosts_per_remote_dc': 0}, - 'type': 'DCAwareRoundRobinPolicy'} - assert insights_registry.serialize(DCAwareRoundRobinPolicy(local_dc='fake_local_dc', - used_hosts_per_remote_dc=15)) == {'namespace': 'cassandra.policies', - 'options': {'local_dc': 'fake_local_dc', 'used_hosts_per_remote_dc': 15}, - 'type': 'DCAwareRoundRobinPolicy'} - - def test_token_aware_policy(self): - assert insights_registry.serialize(TokenAwarePolicy(child_policy=LoadBalancingPolicy())) == {'namespace': 'cassandra.policies', - 'options': {'child_policy': {'namespace': 'cassandra.policies', - 'options': {}, - 'type': 'LoadBalancingPolicy'}, - 'shuffle_replicas': True}, - 'type': 'TokenAwarePolicy'} - - def test_whitelist_round_robin_policy(self): - assert insights_registry.serialize(WhiteListRoundRobinPolicy(['127.0.0.3'])) == {'namespace': 'cassandra.policies', - 'options': {'allowed_hosts': ('127.0.0.3',)}, - 'type': 'WhiteListRoundRobinPolicy'} - - def test_host_filter_policy(self): - def my_predicate(s): - return False - - assert insights_registry.serialize(HostFilterPolicy(LoadBalancingPolicy(), my_predicate)) == {'namespace': 'cassandra.policies', - 'options': {'child_policy': {'namespace': 'cassandra.policies', - 'options': {}, - 'type': 'LoadBalancingPolicy'}, - 'predicate': 'my_predicate'}, - 'type': 'HostFilterPolicy'} - - def test_constant_reconnection_policy(self): - assert insights_registry.serialize(ConstantReconnectionPolicy(3, 200)) == {'type': 'ConstantReconnectionPolicy', - 'namespace': 'cassandra.policies', - 'options': {'delay': 3, 'max_attempts': 200} - } - - def test_exponential_reconnection_policy(self): - assert insights_registry.serialize(ExponentialReconnectionPolicy(4, 100, 10)) == {'type': 'ExponentialReconnectionPolicy', - 'namespace': 'cassandra.policies', - 'options': {'base_delay': 4, 'max_delay': 100, 'max_attempts': 10} - } - - def test_retry_policy(self): - assert insights_registry.serialize(RetryPolicy()) == {'type': 'RetryPolicy', - 'namespace': 'cassandra.policies', - 'options': {} - } - - def test_spec_exec_policy(self): - assert insights_registry.serialize(SpeculativeExecutionPolicy()) == {'type': 'SpeculativeExecutionPolicy', - 'namespace': 'cassandra.policies', - 'options': {} - } - - def test_constant_spec_exec_policy(self): - assert insights_registry.serialize(ConstantSpeculativeExecutionPolicy(100, 101)) == {'type': 'ConstantSpeculativeExecutionPolicy', - 'namespace': 'cassandra.policies', - 'options': {'delay': 100, - 'max_attempts': 101} - } - - def test_wrapper_policy(self): - assert insights_registry.serialize(WrapperPolicy(LoadBalancingPolicy())) == {'namespace': 'cassandra.policies', - 'options': {'child_policy': {'namespace': 'cassandra.policies', - 'options': {}, - 'type': 'LoadBalancingPolicy'} - }, - 'type': 'WrapperPolicy'} diff --git a/tests/unit/test_cluster.py b/tests/unit/test_cluster.py index 3d55bc1860..9a41cf1552 100644 --- a/tests/unit/test_cluster.py +++ b/tests/unit/test_cluster.py @@ -205,7 +205,6 @@ def test_control_connection_query_fallback_modes(self): def test_control_connection_query_fallback_no_node_pool_mode_skips_pool_creation(self): cluster = Cluster( allow_control_connection_query_fallback=ControlConnectionQueryFallback.SkipPoolCreation, - monitor_reporting_enabled=False, ) host = Host("127.0.0.1", SimpleConvictionPolicy, host_id=uuid.uuid4()) @@ -220,7 +219,6 @@ def test_control_connection_query_fallback_no_node_pool_mode_skips_pool_creation def test_control_connection_query_fallback_fallback_tolerates_empty_initial_pools(self): cluster = Cluster( allow_control_connection_query_fallback=ControlConnectionQueryFallback.Fallback, - monitor_reporting_enabled=False, ) host = Host("127.0.0.1", SimpleConvictionPolicy, host_id=uuid.uuid4()) future = Future() From 587793fa0e455cec662d82071cb5649e834083cc Mon Sep 17 00:00:00 2001 From: Roy Dahan Date: Thu, 6 Aug 2026 17:45:13 +0300 Subject: [PATCH 141/200] Add code coverage measurement for unit and integration tests Introduces coverage.py as the coverage tool, wrapping each existing pytest invocation (main unit run plus one per event-loop reactor, plus the integration suite) with `coverage run` and combining the results. Several core modules (cluster.py, connection.py, protocol.py, etc.) are optionally Cython-compiled by default, which coverage.py cannot trace into, so scripts/coverage.sh forces CASS_DRIVER_NO_CYTHON=1 and cleans any stale compiled extensions first (Python's import system otherwise prefers a leftover .so over the .py source, silently producing a false 0% report). Cython-only modules with no .py fallback are a documented, known gap for this method. scripts/coverage.sh tracks test failures in $status rather than using `set -e`, so a failing test still lets coverage combine/report/html/xml run -- otherwise there would be no coverage output at all to diagnose the failure with. Setup/cleanup steps before that point still fail fast. tests/unit/io/test_asyncorereactor.py is included rather than ignored, since it already self-skips via ASYNCCORE_AVAILABLE on Python 3.12+, where asyncore was removed from the stdlib. The GitHub Actions workflow (.github/workflows/coverage.yml) runs this against a live Scylla on every push/PR, posts the text report to the job summary, and uploads the HTML/XML report as a build artifact -- surfaced this way instead of through a third-party service like Codecov, to avoid needing an external account or token for this first pass. Fixes: https://scylladb.atlassian.net/browse/DRIVER-889 Co-Authored-By: Claude Sonnet 5 --- .github/workflows/coverage.yml | 93 ++++++++++++++++++++++++++++++++++ .gitignore | 3 ++ CONTRIBUTING.rst | 22 ++++++++ pyproject.toml | 19 +++++++ scripts/coverage.sh | 64 +++++++++++++++++++++++ 5 files changed, 201 insertions(+) create mode 100644 .github/workflows/coverage.yml create mode 100755 scripts/coverage.sh diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000000..0b4412d6a1 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,93 @@ +name: Code coverage + +on: + push: + branches: + - master + - 'branch-**' + paths-ignore: + - docs/* + - examples/* + - .gitignore + - '*.rst' + - '*.ini' + - LICENSE + - .github/dependabot.yml + - .github/pull_request_template.md + - "*.md" + - .github/workflows/docs-* + pull_request: + paths-ignore: + - docs/* + - examples/* + - .gitignore + - '*.rst' + - '*.ini' + - LICENSE + - .github/dependabot.yml + - .github/pull_request_template.md + - "*.md" + - .github/workflows/docs-* + workflow_dispatch: + +jobs: + coverage: + name: Measure code coverage + if: "!contains(github.event.pull_request.labels.*.name, 'disable-coverage-tests')" + runs-on: ubuntu-24.04 + env: + SCYLLA_VERSION: release:2026.1 + PROTOCOL_VERSION: 4 + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up JDK 8 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + java-version: 8 + distribution: 'adopt' + + - name: Install libev + run: sudo apt-get install libev4 libev-dev + + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + python-version: "3.13" + + - name: Build driver + run: uv sync + + - name: Cache Scylla download + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ~/.ccm/repository + key: scylla-${{ env.SCYLLA_VERSION }}-${{ runner.os }} + + - name: Download Scylla + run: | + uv run ccm create scylla-driver-temp -n 1 --scylla --version ${SCYLLA_VERSION} + uv run ccm remove + + - name: Run tests with coverage + run: bash scripts/coverage.sh + + - name: Publish coverage summary + if: always() + run: | + { + echo '## Coverage report' + echo '```' + uv run coverage report -m + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage-report + path: | + htmlcov/ + coverage.xml diff --git a/.gitignore b/.gitignore index 881012f340..813b520388 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,9 @@ tests/unit/cython/bytesio_testhelper.c # Unit test / coverage reports .coverage +.coverage.* +htmlcov/ +coverage.xml .tox #iPython diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 1227e11e30..7be27b4ea0 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -112,6 +112,28 @@ Use tee to capture logs and see them on your terminal:: uv run pytest -s tests/unit/ 2>&1 | tee test.log +Measuring Code Coverage +------------------------ +``scripts/coverage.sh`` runs the unit suite (all event-loop reactors) and, +if a Scylla/Cassandra version is available, the integration suite, under +``coverage.py``, then combines and reports the result:: + + bash scripts/coverage.sh + + # include the integration suite too + SCYLLA_VERSION="release:2026.1" bash scripts/coverage.sh + +Open ``htmlcov/index.html`` afterwards for a line-by-line, browsable report. +``coverage.xml`` is also produced for tooling that consumes Cobertura-style +XML. + +Note that ``cluster.py``, ``connection.py``, ``protocol.py`` and the other +modules that are optionally Cython-compiled (see ``Dev setup`` above) are +measured as plain Python here, since ``coverage.py`` cannot trace +into compiled extensions -- the script sets ``CASS_DRIVER_NO_CYTHON=1`` for +this reason. Modules that are Cython-only with no pure-Python fallback +(``obj_parser``, ``numpy_parser``, ``row_parser``, and similar) are not built +at all in that mode, so they are not measured by this script. Running the Benchmarks ====================== diff --git a/pyproject.toml b/pyproject.toml index c0979ea097..0d7a042d0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ dev = [ "pyyaml", "numpy", "objgraph", + "coverage[toml]>=7.6", "ccm @ git+https://git@github.com/scylladb/scylla-ccm.git@master", ] @@ -126,6 +127,24 @@ markers = [ version_file = "cassandra/_version.py" tag_regex = '(?P\d*?\.\d*?\.\d*?)-scylla' +[tool.coverage.run] +source = ["cassandra"] +branch = true +parallel = true +relative_files = true +omit = ["cassandra/_version.py"] + +[tool.coverage.report] +show_missing = true +exclude_lines = [ + "pragma: no cover", + "raise NotImplementedError", + "if TYPE_CHECKING:", +] + +[tool.coverage.html] +directory = "htmlcov" + #### CI BUILDWHEEL CONFIG #### [tool.cibuildwheel] diff --git a/scripts/coverage.sh b/scripts/coverage.sh new file mode 100755 index 0000000000..0f8437df84 --- /dev/null +++ b/scripts/coverage.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Runs the unit suite (all event-loop reactors) and, if SCYLLA_VERSION or +# CASSANDRA_VERSION is set, the integration suite, all under coverage.py, then +# combines and reports. +# +# CASS_DRIVER_NO_CYTHON=1 forces cluster.py/connection.py/protocol.py/etc. to +# build as plain Python instead of Cython extensions, since coverage.py can't +# trace into compiled extensions. Cython-only modules with no .py fallback +# (obj_parser, numpy_parser, row_parser, ...) are not built at all in this mode +# and are therefore not measured by this script. +# +# Deliberately not `set -e`: a failing test must not skip report generation +# below, or a broken test leaves no coverage output at all to diagnose it +# with. Each test invocation instead records its own failure into $status, +# and the script exits with that status only after combine/report/html/xml +# have run. +set -uo pipefail +cd "$(dirname "$0")/.." || exit 1 + +rm -f .coverage .coverage.* || exit 1 +export CASS_DRIVER_NO_CYTHON=1 + +# A previous plain `uv sync`/`uv run` may have left Cython-compiled .so/.pyd +# files in place from a normal (Cython-enabled) build. Python's import system +# prefers those over the .py source, so they must be removed -- otherwise +# CASS_DRIVER_NO_CYTHON=1 silently has no effect and coverage reports 0% for +# every affected module. `--reinstall-package` then rebuilds from scratch, +# producing only the extensions CASS_DRIVER_NO_CYTHON=1 actually allows +# (murmur3/libev, but none of the Cython ones). If any of this setup fails, +# there's no point running any tests, so bail out immediately -- failure +# tolerance below is scoped to test/report commands only. +find cassandra -name "*.so" -delete -o -name "*.pyd" -delete || exit 1 +uv sync --reinstall-package scylla-driver || exit 1 + +status=0 + +# Unlike the gevent/eventlet/asyncio reactor tests below, tests/unit/io/ +# test_asyncorereactor.py is deliberately NOT in the --ignore list: it needs +# no separate EVENT_LOOP_MANAGER run, since it self-skips via +# ASYNCCORE_AVAILABLE on Python 3.12+ (where the stdlib `asyncore` module was +# removed) and otherwise runs normally here, gaining coverage on 3.9-3.11. +uv run coverage run -m pytest tests/unit -v \ + --ignore=tests/unit/column_encryption \ + --ignore=tests/unit/io/test_geventreactor.py \ + --ignore=tests/unit/io/test_eventletreactor.py \ + --ignore=tests/unit/io/test_asyncioreactor.py \ + || status=1 + +EVENT_LOOP_MANAGER=gevent uv run coverage run -m pytest tests/unit/io/test_geventreactor.py -v || status=1 +EVENT_LOOP_MANAGER=asyncio uv run coverage run -m pytest tests/unit/io/test_asyncioreactor.py -v || status=1 +EVENT_LOOP_MANAGER=eventlet uv run coverage run -m pytest tests/unit/io/test_eventletreactor.py -v || status=1 + +if [[ -n "${SCYLLA_VERSION:-}" || -n "${CASSANDRA_VERSION:-}" ]]; then + uv run coverage run -m pytest tests/integration/standard tests/integration/cqlengine/ -v || status=1 +else + echo "SCYLLA_VERSION/CASSANDRA_VERSION not set -- skipping integration coverage." +fi + +uv run coverage combine || status=1 +uv run coverage report -m || status=1 +uv run coverage html || status=1 +uv run coverage xml || status=1 + +exit "$status" From 1a89751ffbc293acc1e5551b316d9fcf9b3e9590 Mon Sep 17 00:00:00 2001 From: Roy Dahan Date: Thu, 6 Aug 2026 21:48:08 +0300 Subject: [PATCH 142/200] Address review feedback: concurrency modes, uv.lock, targeted extension cleanup, job-level env - scripts/coverage.sh: pass --concurrency=gevent,thread / --concurrency=eventlet,thread to the gevent/eventlet coverage runs. gevent/eventlet monkey-patch threading/sockets, which can confuse coverage.py's default sys.settrace-based collector without an explicit hint about the greenlet scheduler. - scripts/coverage.sh: replace the blanket `find cassandra -name "*.so" -delete` with a targeted cleanup scoped to the current interpreter's own EXTENSION_SUFFIXES (excluding only cmurmur3/libevwrapper, which CASS_DRIVER_NO_CYTHON doesn't affect). The previous version also deleted every other local Python version's compiled extensions, destroying their builds unnecessarily. It also missed the Cython-only modules with no .py fallback (row_parser, obj_parser, ...) when an earlier, narrower fix only targeted the ten cythonizable .py modules by name -- left in place, those keep HAVE_CYTHON true off a stale .so and silently defeat CASS_DRIVER_NO_CYTHON entirely. Verified locally: the full script still produces the same ~56% baseline it did before, and cmurmur3/libevwrapper survive while HAVE_CYTHON correctly reads False during the run. - .github/workflows/coverage.yml: set CASS_DRIVER_NO_CYTHON=1 at job level instead of only inside coverage.sh, so every step -- including "Build driver" and the summary step, both separate `uv run` invocations -- sees the same value instead of flipping the uv cache-key and triggering a full Cython rebuild between steps. - uv.lock: commit it (was gitignored) and bring it in sync with the coverage[toml] dependency added earlier; `uv lock --check` now passes from a clean checkout. Not doing (tracked as a deliberate follow-up, not a defect): a coverage threshold/regression gate. This first pass establishes a baseline; picking a threshold blind isn't useful yet. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/coverage.yml | 6 + .gitignore | 1 - scripts/coverage.sh | 36 +- uv.lock | 3923 ++++++++++++++++++++++++++++++++ 4 files changed, 3957 insertions(+), 9 deletions(-) create mode 100644 uv.lock diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 0b4412d6a1..8f95376d02 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -38,6 +38,12 @@ jobs: env: SCYLLA_VERSION: release:2026.1 PROTOCOL_VERSION: 4 + # Set once at job level so every step (including "Build driver" and the + # summary step, both of which invoke `uv run`) sees the same value. + # uv's cache-keys include this var, so a value that flips between steps + # makes each `uv run` re-detect a "changed" build config and rebuild the + # Cython extensions from scratch -- about two minutes wasted per flip. + CASS_DRIVER_NO_CYTHON: "1" steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.gitignore b/.gitignore index 813b520388..783700686b 100644 --- a/.gitignore +++ b/.gitignore @@ -46,7 +46,6 @@ coverage.xml #iPython *.ipynb -uv.lock .venv/ diff --git a/scripts/coverage.sh b/scripts/coverage.sh index 0f8437df84..767df74e92 100755 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -24,12 +24,28 @@ export CASS_DRIVER_NO_CYTHON=1 # files in place from a normal (Cython-enabled) build. Python's import system # prefers those over the .py source, so they must be removed -- otherwise # CASS_DRIVER_NO_CYTHON=1 silently has no effect and coverage reports 0% for -# every affected module. `--reinstall-package` then rebuilds from scratch, -# producing only the extensions CASS_DRIVER_NO_CYTHON=1 actually allows -# (murmur3/libev, but none of the Cython ones). If any of this setup fails, -# there's no point running any tests, so bail out immediately -- failure -# tolerance below is scoped to test/report commands only. -find cassandra -name "*.so" -delete -o -name "*.pyd" -delete || exit 1 +# every affected module (and, for the Cython-only modules with no .py +# fallback like row_parser, HAVE_CYTHON would stay True off a stale .so, +# defeating CASS_DRIVER_NO_CYTHON entirely). Only extensions matching the +# *current* interpreter's own EXTENSION_SUFFIXES are removed -- the same +# mechanism tests/conftest.py already uses to detect staleness -- so this +# doesn't force a rebuild for other Python versions/venvs sharing this +# checkout. murmur3/libev are excluded by name since they're unaffected by +# CASS_DRIVER_NO_CYTHON. `--reinstall-package` then rebuilds from scratch, +# producing only the extensions CASS_DRIVER_NO_CYTHON=1 actually allows. If +# any of this setup fails, there's no point running any tests, so bail out +# immediately -- failure tolerance below is scoped to test/report commands +# only. +uv run python -c " +import importlib.machinery, pathlib +exclude = {'cmurmur3', 'libevwrapper'} +for path in pathlib.Path('cassandra').rglob('*'): + for suffix in importlib.machinery.EXTENSION_SUFFIXES: + if path.name.endswith(suffix): + if path.name[:-len(suffix)] not in exclude: + path.unlink() + break +" || exit 1 uv sync --reinstall-package scylla-driver || exit 1 status=0 @@ -46,9 +62,13 @@ uv run coverage run -m pytest tests/unit -v \ --ignore=tests/unit/io/test_asyncioreactor.py \ || status=1 -EVENT_LOOP_MANAGER=gevent uv run coverage run -m pytest tests/unit/io/test_geventreactor.py -v || status=1 +# gevent/eventlet monkey-patch threading/sockets, which can confuse +# coverage.py's default sys.settrace-based collector; --concurrency tells it +# about the greenlet scheduler explicitly. asyncio and the default (thread) +# runs need no such hint. +EVENT_LOOP_MANAGER=gevent uv run coverage run --concurrency=gevent,thread -m pytest tests/unit/io/test_geventreactor.py -v || status=1 EVENT_LOOP_MANAGER=asyncio uv run coverage run -m pytest tests/unit/io/test_asyncioreactor.py -v || status=1 -EVENT_LOOP_MANAGER=eventlet uv run coverage run -m pytest tests/unit/io/test_eventletreactor.py -v || status=1 +EVENT_LOOP_MANAGER=eventlet uv run coverage run --concurrency=eventlet,thread -m pytest tests/unit/io/test_eventletreactor.py -v || status=1 if [[ -n "${SCYLLA_VERSION:-}" || -n "${CASSANDRA_VERSION:-}" ]]; then uv run coverage run -m pytest tests/integration/standard tests/integration/cqlengine/ -v || status=1 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000000..26e86f3eaf --- /dev/null +++ b/uv.lock @@ -0,0 +1,3923 @@ +version = 1 +revision = 3 +requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", + "python_full_version >= '3.9.12' and python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.9.12'", + "python_full_version <= '3.9'", +] + +[[package]] +name = "aenum" +version = "3.1.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/07/e9/8b283567c1fef7c24d1f390b37daede8b61593d8cdaffb8e95d571699e83/aenum-3.1.17.tar.gz", hash = "sha256:a969a4516b194895de72c875ece355f17c0d272146f7fda346ef74f93cf4d5ba", size = 137648, upload-time = "2026-03-20T20:43:29.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/8d/1fe30c6fd8999b9d462547c4a1bb6690bda24af38f2913c4bec7decb81f2/aenum-3.1.17-py3-none-any.whl", hash = "sha256:8b883a37a04e74cc838ac442bdd28c266eae5bbf13e1342c7ef123ed25230139", size = 165560, upload-time = "2026-03-20T20:43:27.681Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9.12' and python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.9.12'", + "python_full_version <= '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9.12' and python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.9.12'", + "python_full_version <= '3.9'", +] +dependencies = [ + { name = "aiohappyeyeballs", version = "2.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "aiosignal", marker = "python_full_version < '3.10'" }, + { name = "async-timeout", marker = "python_full_version < '3.10'" }, + { name = "attrs", marker = "python_full_version < '3.10'" }, + { name = "frozenlist", marker = "python_full_version < '3.10'" }, + { name = "multidict", marker = "python_full_version < '3.10'" }, + { name = "propcache", version = "0.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "yarl", version = "1.22.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/85/cebc47ee74d8b408749073a1a46c6fcba13d170dc8af7e61996c6c9394ac/aiohttp-3.13.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:02222e7e233295f40e011c1b00e3b0bd451f22cf853a0304c3595633ee47da4b", size = 750547, upload-time = "2026-03-31T21:56:30.024Z" }, + { url = "https://files.pythonhosted.org/packages/05/98/afd308e35b9d3d8c9ec54c0918f1d722c86dc17ddfec272fcdbcce5a3124/aiohttp-3.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bace460460ed20614fa6bc8cb09966c0b8517b8c58ad8046828c6078d25333b5", size = 503535, upload-time = "2026-03-31T21:56:31.935Z" }, + { url = "https://files.pythonhosted.org/packages/6f/4d/926c183e06b09d5270a309eb50fbde7b09782bfd305dec1e800f329834fb/aiohttp-3.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f546a4dc1e6a5edbb9fd1fd6ad18134550e096a5a43f4ad74acfbd834fc6670", size = 497830, upload-time = "2026-03-31T21:56:33.654Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d6/f47d1c690f115a5c2a5e8938cce4a232a5be9aac5c5fb2647efcbbbda333/aiohttp-3.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c86969d012e51b8e415a8c6ce96f7857d6a87d6207303ab02d5d11ef0cad2274", size = 1682474, upload-time = "2026-03-31T21:56:35.513Z" }, + { url = "https://files.pythonhosted.org/packages/01/44/056fd37b1bb52eac760303e5196acc74d9d546631b035704ae5927f7b4ac/aiohttp-3.13.5-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b6f6cd1560c5fa427e3b6074bb24d2c64e225afbb7165008903bd42e4e33e28a", size = 1655259, upload-time = "2026-03-31T21:56:37.843Z" }, + { url = "https://files.pythonhosted.org/packages/91/9f/78eb1a20c1c28ae02f6a3c0f4d7b0dcc66abce5290cadd53d78ce3084175/aiohttp-3.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:636bc362f0c5bbc7372bc3ae49737f9e3030dbce469f0f422c8f38079780363d", size = 1736204, upload-time = "2026-03-31T21:56:39.822Z" }, + { url = "https://files.pythonhosted.org/packages/de/6c/d20d7de23f0b52b8c1d9e2033b2db1ac4dacbb470bb74c56de0f5f86bb4f/aiohttp-3.13.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6a7cbeb06d1070f1d14895eeeed4dac5913b22d7b456f2eb969f11f4b3993796", size = 1826198, upload-time = "2026-03-31T21:56:41.378Z" }, + { url = "https://files.pythonhosted.org/packages/2f/86/a6f3ff1fd795f49545a7c74b2c92f62729135d73e7e4055bf74da5a26c82/aiohttp-3.13.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca9ef7517fd7874a1a08970ae88f497bf5c984610caa0bf40bd7e8450852b95", size = 1681329, upload-time = "2026-03-31T21:56:43.374Z" }, + { url = "https://files.pythonhosted.org/packages/fb/68/84cd3dab6b7b4f3e6fe9459a961acb142aaab846417f6e8905110d7027e5/aiohttp-3.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:019a67772e034a0e6b9b17c13d0a8fe56ad9fb150fc724b7f3ffd3724288d9e5", size = 1560023, upload-time = "2026-03-31T21:56:45.031Z" }, + { url = "https://files.pythonhosted.org/packages/41/2c/db61b64b0249e30f954a65ab4cb4970ced57544b1de2e3c98ee5dc24165f/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f34ecee82858e41dd217734f0c41a532bd066bcaab636ad830f03a30b2a96f2a", size = 1652372, upload-time = "2026-03-31T21:56:47.075Z" }, + { url = "https://files.pythonhosted.org/packages/25/6f/e96988a6c982d047810c772e28c43c64c300c943b0ed5c1c0c4ce1e1027c/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4eac02d9af4813ee289cd63a361576da36dba57f5a1ab36377bc2600db0cbb73", size = 1662031, upload-time = "2026-03-31T21:56:48.835Z" }, + { url = "https://files.pythonhosted.org/packages/b7/26/a56feace81f3d347b4052403a9d03754a0ab23f7940780dada0849a38c92/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4beac52e9fe46d6abf98b0176a88154b742e878fdf209d2248e99fcdf73cd297", size = 1708118, upload-time = "2026-03-31T21:56:50.833Z" }, + { url = "https://files.pythonhosted.org/packages/78/6e/b6173a8ff03d01d5e1a694bc06764b5dad1df2d4ed8f0ceec12bb3277936/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c180f480207a9b2475f2b8d8bd7204e47aec952d084b2a2be58a782ffcf96074", size = 1548667, upload-time = "2026-03-31T21:56:52.81Z" }, + { url = "https://files.pythonhosted.org/packages/16/13/13296ffe2c132d888b3fe2c195c8b9c0c24c89c3fa5cc2c44464dc23b22e/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2837fb92951564d6339cedae4a7231692aa9f73cbc4fb2e04263b96844e03b4e", size = 1724490, upload-time = "2026-03-31T21:56:54.541Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1f1c287f4a79782ef36e5a6e62954c85343bc30470d862d30bd5f26c9fa2/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d9010032a0b9710f58012a1e9c222528763d860ba2ee1422c03473eab47703e7", size = 1667109, upload-time = "2026-03-31T21:56:56.21Z" }, + { url = "https://files.pythonhosted.org/packages/ef/42/8461a2aaf60a8f4ea4549a4056be36b904b0eb03d97ca9a8a2604681a500/aiohttp-3.13.5-cp310-cp310-win32.whl", hash = "sha256:7c4b6668b2b2b9027f209ddf647f2a4407784b5d88b8be4efcc72036f365baf9", size = 439478, upload-time = "2026-03-31T21:56:58.292Z" }, + { url = "https://files.pythonhosted.org/packages/e5/71/06956304cb5ee439dfe8d86e1b2e70088bd88ed1ced1f42fb29e5d855f0e/aiohttp-3.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:cd3db5927bf9167d5a6157ddb2f036f6b6b0ad001ac82355d43e97a4bde76d76", size = 462047, upload-time = "2026-03-31T21:57:00.257Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/a20c4ac64aeaef1679e25c9983573618ff765d7aa829fa2b84ae7573169e/aiohttp-3.13.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ab7229b6f9b5c1ba4910d6c41a9eb11f543eadb3f384df1b4c293f4e73d44d6", size = 757513, upload-time = "2026-03-31T21:57:02.146Z" }, + { url = "https://files.pythonhosted.org/packages/75/0a/39fa6c6b179b53fcb3e4b3d2b6d6cad0180854eda17060c7218540102bef/aiohttp-3.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8f14c50708bb156b3a3ca7230b3d820199d56a48e3af76fa21c2d6087190fe3d", size = 506748, upload-time = "2026-03-31T21:57:04.275Z" }, + { url = "https://files.pythonhosted.org/packages/87/ec/e38ce072e724fd7add6243613f8d1810da084f54175353d25ccf9f9c7e5a/aiohttp-3.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7d2f8616f0ff60bd332022279011776c3ac0faa0f1b463f7bb12326fbc97a1c", size = 501673, upload-time = "2026-03-31T21:57:06.208Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ba/3bc7525d7e2beaa11b309a70d48b0d3cfc3c2089ec6a7d0820d59c657053/aiohttp-3.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2567b72e1ffc3ab25510db43f355b29eeada56c0a622e58dcdb19530eb0a3cb", size = 1763757, upload-time = "2026-03-31T21:57:07.882Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ab/e87744cf18f1bd78263aba24924d4953b41086bd3a31d22452378e9028a0/aiohttp-3.13.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fb0540c854ac9c0c5ad495908fdfd3e332d553ec731698c0e29b1877ba0d2ec6", size = 1720152, upload-time = "2026-03-31T21:57:09.946Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f3/ed17a6f2d742af17b50bae2d152315ed1b164b07a5fd5cc1754d99e4dfa5/aiohttp-3.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9883051c6972f58bfc4ebb2116345ee2aa151178e99c3f2b2bbe2af712abd13", size = 1818010, upload-time = "2026-03-31T21:57:12.157Z" }, + { url = "https://files.pythonhosted.org/packages/53/06/ecbc63dc937192e2a5cb46df4d3edb21deb8225535818802f210a6ea5816/aiohttp-3.13.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2294172ce08a82fb7c7273485895de1fa1186cc8294cfeb6aef4af42ad261174", size = 1907251, upload-time = "2026-03-31T21:57:14.023Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a5/0521aa32c1ddf3aa1e71dcc466be0b7db2771907a13f18cddaa45967d97b/aiohttp-3.13.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a807cabd5115fb55af198b98178997a5e0e57dead43eb74a93d9c07d6d4a7dc", size = 1759969, upload-time = "2026-03-31T21:57:16.146Z" }, + { url = "https://files.pythonhosted.org/packages/f6/78/a38f8c9105199dd3b9706745865a8a59d0041b6be0ca0cc4b2ccf1bab374/aiohttp-3.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6d0d932e0f39c02b80744273cd5c388a2d9bc07760a03164f229c8e02662f6", size = 1616871, upload-time = "2026-03-31T21:57:17.856Z" }, + { url = "https://files.pythonhosted.org/packages/6f/41/27392a61ead8ab38072105c71aa44ff891e71653fe53d576a7067da2b4e8/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60869c7ac4aaabe7110f26499f3e6e5696eae98144735b12a9c3d9eae2b51a49", size = 1739844, upload-time = "2026-03-31T21:57:19.679Z" }, + { url = "https://files.pythonhosted.org/packages/6e/55/5564e7ae26d94f3214250009a0b1c65a0c6af4bf88924ccb6fdab901de28/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:26d2f8546f1dfa75efa50c3488215a903c0168d253b75fba4210f57ab77a0fb8", size = 1731969, upload-time = "2026-03-31T21:57:22.006Z" }, + { url = "https://files.pythonhosted.org/packages/6d/c5/705a3929149865fc941bcbdd1047b238e4a72bcb215a9b16b9d7a2e8d992/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1162a1492032c82f14271e831c8f4b49f2b6078f4f5fc74de2c912fa225d51d", size = 1795193, upload-time = "2026-03-31T21:57:24.256Z" }, + { url = "https://files.pythonhosted.org/packages/a6/19/edabed62f718d02cff7231ca0db4ef1c72504235bc467f7b67adb1679f48/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8b14eb3262fad0dc2f89c1a43b13727e709504972186ff6a99a3ecaa77102b6c", size = 1606477, upload-time = "2026-03-31T21:57:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/de/fc/76f80ef008675637d88d0b21584596dc27410a990b0918cb1e5776545b5b/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ca9ac61ac6db4eb6c2a0cd1d0f7e1357647b638ccc92f7e9d8d133e71ed3c6ac", size = 1813198, upload-time = "2026-03-31T21:57:28.316Z" }, + { url = "https://files.pythonhosted.org/packages/e5/67/5b3ac26b80adb20ea541c487f73730dc8fa107d632c998f25bbbab98fcda/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7996023b2ed59489ae4762256c8516df9820f751cf2c5da8ed2fb20ee50abab3", size = 1752321, upload-time = "2026-03-31T21:57:30.549Z" }, + { url = "https://files.pythonhosted.org/packages/88/06/e4a2e49255ea23fa4feeb5ab092d90240d927c15e47b5b5c48dff5a9ce29/aiohttp-3.13.5-cp311-cp311-win32.whl", hash = "sha256:77dfa48c9f8013271011e51c00f8ada19851f013cde2c48fca1ba5e0caf5bb06", size = 439069, upload-time = "2026-03-31T21:57:32.388Z" }, + { url = "https://files.pythonhosted.org/packages/c0/43/8c7163a596dab4f8be12c190cf467a1e07e4734cf90eebb39f7f5d53fc6a/aiohttp-3.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:d3a4834f221061624b8887090637db9ad4f61752001eae37d56c52fddade2dc8", size = 462859, upload-time = "2026-03-31T21:57:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" }, + { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" }, + { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" }, + { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" }, + { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" }, + { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" }, + { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" }, + { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" }, + { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" }, + { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" }, + { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" }, + { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" }, + { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" }, + { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" }, + { url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" }, + { url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" }, + { url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" }, + { url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" }, + { url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" }, + { url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" }, + { url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" }, + { url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" }, + { url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" }, + { url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" }, + { url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" }, + { url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" }, + { url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" }, + { url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" }, + { url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" }, + { url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" }, + { url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" }, + { url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" }, + { url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" }, + { url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" }, + { url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" }, + { url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" }, + { url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" }, + { url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" }, + { url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" }, + { url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" }, + { url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" }, + { url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" }, + { url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" }, + { url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" }, + { url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" }, + { url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" }, + { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a5/630bc484695d4a1342bbae85fb8689bf979106525684fc88f05b397324ad/aiohttp-3.13.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:347542f0ea3f95b2a955ee6656461fa1c776e401ac50ebce055a6c38454a0adf", size = 752872, upload-time = "2026-03-31T22:00:15.553Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b8/6a19dda37fda94a9ebefb3c1ae0ff419ac7fbf4fb40750e992829fc13614/aiohttp-3.13.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:178c7b5e62b454c2bc790786e6058c3cc968613b4419251b478c153a4aec32b1", size = 504582, upload-time = "2026-03-31T22:00:18.191Z" }, + { url = "https://files.pythonhosted.org/packages/d5/34/8413eafee3421ade2d6ce9e7c0da1213e1d7f0049be09dcdc342b03a39ba/aiohttp-3.13.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:af545c2cffdb0967a96b6249e6f5f7b0d92cdfd267f9d5238d5b9ca63e8edb10", size = 499094, upload-time = "2026-03-31T22:00:21.118Z" }, + { url = "https://files.pythonhosted.org/packages/da/cf/c6f97006093d1e8ca40fbab843ff49ec7725ab668f0714dd1cb702c62cbd/aiohttp-3.13.5-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:206b7b3ef96e4ce211754f0cd003feb28b7d81f0ad26b8d077a5d5161436067f", size = 1669505, upload-time = "2026-03-31T22:00:24.01Z" }, + { url = "https://files.pythonhosted.org/packages/c2/27/3b2288e66dcec8b04771b2bee3909f70e4072bea995cde5ab7e775e73ddc/aiohttp-3.13.5-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ee5e86776273de1795947d17bddd6bb19e0365fd2af4289c0d2c5454b6b1d36b", size = 1648928, upload-time = "2026-03-31T22:00:27.001Z" }, + { url = "https://files.pythonhosted.org/packages/3a/7f/605d766887594a88dcc27a19663499c7c5e13e7aa87f129b763765a2ee63/aiohttp-3.13.5-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:95d14ca7abefde230f7639ec136ade282655431fd5db03c343b19dda72dd1643", size = 1731800, upload-time = "2026-03-31T22:00:29.603Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/5a878e728e30699d22b118f1a6ad576ab6fff9eb2c6fc8a7faa9376a1c3e/aiohttp-3.13.5-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:912d4b6af530ddb1338a66229dac3a25ff11d4448be3ec3d6340583995f56031", size = 1824247, upload-time = "2026-03-31T22:00:32.139Z" }, + { url = "https://files.pythonhosted.org/packages/37/99/84b448291e9996bb83bf4fad3a71a9786d542f19c50a3ff0531bfaba6fac/aiohttp-3.13.5-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e999f0c88a458c836d5fb521814e92ed2172c649200336a6df514987c1488258", size = 1670742, upload-time = "2026-03-31T22:00:34.788Z" }, + { url = "https://files.pythonhosted.org/packages/14/a8/d8d5d1ab6d29a4a3bdb9db31f161e338bfdf6638f6574ea8380f1d4a243c/aiohttp-3.13.5-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39380e12bd1f2fdab4285b6e055ad48efbaed5c836433b142ed4f5b9be71036a", size = 1562474, upload-time = "2026-03-31T22:00:37.623Z" }, + { url = "https://files.pythonhosted.org/packages/92/e8/bd889697916f10b65524422c61b4eeaf919eb35a170290cccb680cbe4eb4/aiohttp-3.13.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9efcc0f11d850cefcafdd9275b9576ad3bfb539bed96807663b32ad99c4d4b88", size = 1642235, upload-time = "2026-03-31T22:00:40.541Z" }, + { url = "https://files.pythonhosted.org/packages/60/42/3f1928107131f1413a5972ace14ddcd5364968e9bd7b3ad71272defafc9c/aiohttp-3.13.5-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:147b4f501d0292077f29d5268c16bb7c864a1f054d7001c4c1812c0421ea1ed0", size = 1655397, upload-time = "2026-03-31T22:00:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/b2/79/c4bbcf4cac3a4715a326e49720ccdc3a4b5e14a367c5029eae7727d06029/aiohttp-3.13.5-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:d147004fede1b12f6013a6dbb2a26a986a671a03c6ea740ddc76500e5f1c399f", size = 1703509, upload-time = "2026-03-31T22:00:45.908Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e6/32d245876f211a7308a7d5437707f9296b1f9837a2888a407ed04e61321c/aiohttp-3.13.5-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:9277145d36a01653863899c665243871434694bcc3431922c3b35c978061bdb8", size = 1550098, upload-time = "2026-03-31T22:00:49.48Z" }, + { url = "https://files.pythonhosted.org/packages/db/62/ab0f1304def56ce2356e6fbb9f0b024d6544010351430070f48f53b89e0a/aiohttp-3.13.5-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4e704c52438f66fdd89588346183d898bb42167cf88f8b7ff1c0f9fc957c348f", size = 1724326, upload-time = "2026-03-31T22:00:52.165Z" }, + { url = "https://files.pythonhosted.org/packages/c4/9a/aab4469689024046220ea438aa020ea2ae04cd1dd71aea3057e094f8c357/aiohttp-3.13.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a8a4d3427e8de1312ddf309cc482186466c79895b3a139fed3259fc01dfa9a5b", size = 1658824, upload-time = "2026-03-31T22:00:55.122Z" }, + { url = "https://files.pythonhosted.org/packages/b0/98/bcc35d4db687acabf06d41f561a99fa88bca145292513388c858d99b72c5/aiohttp-3.13.5-cp39-cp39-win32.whl", hash = "sha256:6f497a6876aa4b1a102b04996ce4c1170c7040d83faa9387dd921c16e30d5c83", size = 440302, upload-time = "2026-03-31T22:00:57.673Z" }, + { url = "https://files.pythonhosted.org/packages/25/61/b0203c2ef6bd268fca0eda142f0efbba7cbebd7ad38f7bb01dd31c2ff68e/aiohttp-3.13.5-cp39-cp39-win_amd64.whl", hash = "sha256:cb979826071c0986a5f08333a36104153478ce6018c58cba7f9caddaf63d5d67", size = 463076, upload-time = "2026-03-31T22:01:00.264Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "aiohappyeyeballs", version = "2.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "aiosignal", marker = "python_full_version >= '3.10'" }, + { name = "async-timeout", marker = "python_full_version == '3.10.*'" }, + { name = "attrs", marker = "python_full_version >= '3.10'" }, + { name = "frozenlist", marker = "python_full_version >= '3.10'" }, + { name = "multidict", marker = "python_full_version >= '3.10'" }, + { name = "propcache", version = "0.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, + { name = "yarl", version = "1.24.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/4d/4a99fb425c5e0cad715eea7bd190aff46f38b959a0a2dadb993705d34b26/aiohttp-3.14.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:eb0495d778817619273c108784292be161a924b9f5ae5cbbc70a2caa6838250b", size = 765848, upload-time = "2026-07-23T01:52:08.217Z" }, + { url = "https://files.pythonhosted.org/packages/74/e8/43b85dc55b8e950dc644babe762add781319ea881b57b33d2cce12017d12/aiohttp-3.14.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c3c200cf9757edd785051dc699c7ecbec22110dbfcb3fefc7a9f9695eda8ea7a", size = 517476, upload-time = "2026-07-23T01:52:10.846Z" }, + { url = "https://files.pythonhosted.org/packages/7f/9e/73b582c4dbbc3c12ef4473822475effaabf1f934b56f14f5b03fe5d3a2af/aiohttp-3.14.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd51ebf9d3a00c074df4ede271023f4d2dba289bcc740b88191872716014e3c5", size = 515334, upload-time = "2026-07-23T01:52:12.636Z" }, + { url = "https://files.pythonhosted.org/packages/79/03/e98c3c9e05a5bdf97defe5ff9169baba4f0ec9a901f2d60e0f060c2f051e/aiohttp-3.14.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:134ac5ddcf61c6fad984b9a5727d83492ada43d63471db20fb73042c13fca62f", size = 1708830, upload-time = "2026-07-23T01:52:14.538Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2c/26e60b694844dfd2176c57f913a22d0cd6a16f9ff202cbda7580d0328b98/aiohttp-3.14.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:70c987b27534f9ae1a723f47ae921571d616da21d3208282bf4c52af5164ac43", size = 1674012, upload-time = "2026-07-23T01:52:16.486Z" }, + { url = "https://files.pythonhosted.org/packages/38/65/672df92e3172cd876aacfa97a952ac560877eb169384b2991ac5b273de4c/aiohttp-3.14.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1b59533861b70a2185c8f4f350f791f39d64358ef6944ce71c5240c9ec0982c9", size = 1767015, upload-time = "2026-07-23T01:52:18.28Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c5/228dec7bfec1c373cc2217cdeb47d6456dcd7a13a4c55144930a75ae3851/aiohttp-3.14.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c5281acc88b92396f88c7e1e2748f8466689df22b80170e4f51efa712fb47a8", size = 1858700, upload-time = "2026-07-23T01:52:20.08Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ff/cb36724e8c8d17f90ada567a9ff3efe1d6e9b549fba697a242aece180f21/aiohttp-3.14.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48d67b87db6279c044760787eb01f6413032c2e6f3ba1cafaa492b1c8e578479", size = 1714075, upload-time = "2026-07-23T01:52:22.071Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3a/296a4135c6366376263aeef54b15caca1f07676c2ae0c525d7832f2f808a/aiohttp-3.14.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f53bcd52f585e1ac3e590d61434eb61f9a88c38df041b4ea126d97144344a77b", size = 1588234, upload-time = "2026-07-23T01:52:23.757Z" }, + { url = "https://files.pythonhosted.org/packages/7d/81/9d5d853ef892dc066d1eb6db0e87a47348b920c1c879aa554612fdbd9d79/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0fdea2281997af69da84c77ffa6f5938a0285f21fb3887c249d67419ca865b3d", size = 1677300, upload-time = "2026-07-23T01:52:25.861Z" }, + { url = "https://files.pythonhosted.org/packages/68/96/021d386ae32d9b26d4b88df2e794546232ff56bb6be952bf6be227c0bbc7/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:cda5fd5c95ad7a125a2e8464acc78b98b94c475a3780d6aa0aa157c93f470f4d", size = 1691501, upload-time = "2026-07-23T01:52:28Z" }, + { url = "https://files.pythonhosted.org/packages/29/9f/af66adce26a14af135c003cbd0f44ccaa68cebd30ff8ac99ca47fb4958f7/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:6debfa7312ff9d4c124dc71d72e9a0a4b9e0879e48ba6fcb42bef5c3300289e2", size = 1735113, upload-time = "2026-07-23T01:52:29.995Z" }, + { url = "https://files.pythonhosted.org/packages/2f/90/28c390d4c9851effe52ac25b5a2e1d92246acd00728b4fc7975dafb67484/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f4e05329faa0ea1a404b37de4f034fd2c2defcca06a68dc6745e4e56c88e8a48", size = 1577486, upload-time = "2026-07-23T01:52:31.937Z" }, + { url = "https://files.pythonhosted.org/packages/db/c2/00e23a1bf2abb70dd353f6987db7e7f2491d0261f7363997738c71c98f95/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a3a8296e7ab5c295f53f1041487cb088e1480775aafbf7fe545d93b770a0f96f", size = 1751353, upload-time = "2026-07-23T01:52:33.688Z" }, + { url = "https://files.pythonhosted.org/packages/6e/7d/d51a706a8cbfa57f0611127daf61ab3ae02ab8420b0407412079227d1c65/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5373dc80ad1aa2fb9ad95c83f24eef418bbda3a61375f128e5b0192e4f3f9b32", size = 1698681, upload-time = "2026-07-23T01:52:38.167Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b0/90bd5cd9fdd9787cb4211d284d1fb8401339a933cb0227a15b71e789232f/aiohttp-3.14.3-cp310-cp310-win32.whl", hash = "sha256:a3e22975f905b89a55a488c2a08f2fdb2186175349e917d48985cc468a3d4c6e", size = 456733, upload-time = "2026-07-23T01:52:41.823Z" }, + { url = "https://files.pythonhosted.org/packages/d8/15/fe5b8f6a71ae112bc677163d0b0701bda5dc15005249582258ede0eb88c7/aiohttp-3.14.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdd0e2834dce1a26c1bbe26464861e16bbe217042cbff619247c11594472518c", size = 480460, upload-time = "2026-07-23T01:52:43.905Z" }, + { url = "https://files.pythonhosted.org/packages/54/00/45e98b6645cd7f00a4b78b749ebd309094b0eaeb2d2e96157eadbc0d0050/aiohttp-3.14.3-cp310-cp310-win_arm64.whl", hash = "sha256:eac645b09bcfdf73df7536331f0678c1086ea250981118ddb5199e17ccef72bb", size = 453479, upload-time = "2026-07-23T01:52:46.075Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5c/b3e4ff8ad43a8afef9602c5e90285936da1beaea8b029016b793891f03c3/aiohttp-3.14.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3", size = 764250, upload-time = "2026-07-23T01:52:48.525Z" }, + { url = "https://files.pythonhosted.org/packages/0e/da/f1b384465e51449d844056b75070461da03a9a23e6c1747003695bf4172a/aiohttp-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a", size = 516281, upload-time = "2026-07-23T01:52:51.047Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3f/01264f820ee2e3712a827892b1cd6ff80f3300c1fcbffbb45714a915d47a/aiohttp-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8", size = 514742, upload-time = "2026-07-23T01:52:53.779Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8d/a71c6f2db52ac1ed142b133f7feddaa6b70539c3f4de24d7e226c95b794c/aiohttp-3.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239", size = 1780613, upload-time = "2026-07-23T01:52:56.948Z" }, + { url = "https://files.pythonhosted.org/packages/a5/11/3dd9b3fb3a170f6ec9011b5291d876a6fab4086714c9e158600edf01b4fd/aiohttp-3.14.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f", size = 1737688, upload-time = "2026-07-23T01:52:59.294Z" }, + { url = "https://files.pythonhosted.org/packages/6d/3e/834c26918be7d88068822b40e0db30fca50b5f4fe79104aa16a93f1d74e6/aiohttp-3.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06", size = 1845742, upload-time = "2026-07-23T01:53:01.641Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c9/49ab8572df7d66bc13d11e31f781292badb04180dd87ba98733066c6aed7/aiohttp-3.14.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929", size = 1928412, upload-time = "2026-07-23T01:53:04.018Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/2b8f0c0ce09c87a1daf80fd483431b56b1435d3f62789bc86f572e1245de/aiohttp-3.14.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db", size = 1786220, upload-time = "2026-07-23T01:53:06.481Z" }, + { url = "https://files.pythonhosted.org/packages/85/00/9c45f81de11710460edfa1dc81317b6e882703b160926c879a9d20da9fcc/aiohttp-3.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce", size = 1637231, upload-time = "2026-07-23T01:53:10.258Z" }, + { url = "https://files.pythonhosted.org/packages/19/ce/967d628e910756f3539c6107cb7844a1b69440dcb3029a5ee7871b09ab63/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c", size = 1753161, upload-time = "2026-07-23T01:53:13.817Z" }, + { url = "https://files.pythonhosted.org/packages/11/b2/0c3d4114f0aee4f580f5b3b4eb71b24d7a23b834ea506a4dfebe76513f35/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15", size = 1756356, upload-time = "2026-07-23T01:53:16.211Z" }, + { url = "https://files.pythonhosted.org/packages/63/5d/99e7d91c82f1399d1ae2a854e080bd1493fbc31e5e959dbc4ec33dac3bec/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c", size = 1819846, upload-time = "2026-07-23T01:53:18.289Z" }, + { url = "https://files.pythonhosted.org/packages/ad/05/d5e1cb6480eeffd3f901d40a2c5e2d1e7effdc797837da3b490272699f13/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae", size = 1628531, upload-time = "2026-07-23T01:53:23.86Z" }, + { url = "https://files.pythonhosted.org/packages/c9/90/b934682bcaefae18a9e04f3dff5b68522ba810906358ae5029b68110ea3b/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910", size = 1832712, upload-time = "2026-07-23T01:53:27.551Z" }, + { url = "https://files.pythonhosted.org/packages/21/df/6061679faaf81fac746e7307c7adb71e858071a5d34c27583afefc64f543/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7", size = 1775014, upload-time = "2026-07-23T01:53:30.223Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1d/f854878bbc69b88faefe924b619a34a6f59ec05fd387c77690667eaa75eb/aiohttp-3.14.3-cp311-cp311-win32.whl", hash = "sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa", size = 456006, upload-time = "2026-07-23T01:53:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/73/0c/2af9d1674baccd1dbd47282a93d660a22e57ef6167c856deb24b4214fbab/aiohttp-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d", size = 481069, upload-time = "2026-07-23T01:53:39.673Z" }, + { url = "https://files.pythonhosted.org/packages/8e/76/88401ff3fc95e85c5fc38d588f36f55e61ecb64343b2bc8d69326f453cc0/aiohttp-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39", size = 453021, upload-time = "2026-07-23T01:53:43.749Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" }, + { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" }, + { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" }, + { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" }, + { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" }, + { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" }, + { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" }, + { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" }, + { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" }, + { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" }, + { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" }, + { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, + { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, + { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, + { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, + { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, + { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, + { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, + { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, + { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, + { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, + { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, + { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, + { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, + { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, + { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +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 = "4.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/87/d6/21b30a550dafea84b1b8eee21b5e23fa16d010ae006011221f33dcd8d7f8/async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f", size = 8345, upload-time = "2023-08-10T16:35:56.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/fa/e01228c2938de91d47b307831c62ab9e4001e747789d0b05baf779a6488c/async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028", size = 5721, upload-time = "2023-08-10T16:35:55.203Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "automat" +version = "25.4.16" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/0f/d40bbe294bbf004d436a8bcbcfaadca8b5140d39ad0ad3d73d1a8ba15f14/automat-25.4.16.tar.gz", hash = "sha256:0017591a5477066e90d26b0e696ddc143baafd87b588cfac8100bc6be9634de0", size = 129977, upload-time = "2025-04-16T20:12:16.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/ff/1175b0b7371e46244032d43a56862d0af455823b5280a50c63d99cc50f18/automat-25.4.16-py3-none-any.whl", hash = "sha256:04e9bce696a8d5671ee698005af6e5a9fa15354140a87f4870744604dcdd3ba1", size = 42842, upload-time = "2025-04-16T20:12:14.447Z" }, +] + +[[package]] +name = "boto3" +version = "1.42.97" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9.12' and python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.9.12'", + "python_full_version <= '3.9'", +] +dependencies = [ + { name = "botocore", version = "1.42.97", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "jmespath", marker = "python_full_version < '3.10'" }, + { name = "s3transfer", version = "0.16.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/7d/5c6fa0bb9fd5caf865b9356411793900304328bcd0bc1eda96a32a1368a6/boto3-1.42.97.tar.gz", hash = "sha256:2833dbeda3670ea610ad48dff7d27cdc829dbbfcdfbc6b750b673948e949b6f0", size = 113217, upload-time = "2026-04-27T20:39:17.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/43/84c1888139aa1aaf1dc53f8f914e6ec629e5a571fbafdd42fb2d98ac361f/boto3-1.42.97-py3-none-any.whl", hash = "sha256:966e49f0510af9a64057a902b7df53d4348c447de0d3df4cc855dfd85e058fcd", size = 140556, upload-time = "2026-04-27T20:39:15.509Z" }, +] + +[[package]] +name = "boto3" +version = "1.43.64" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "botocore", version = "1.43.64", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "jmespath", marker = "python_full_version >= '3.10'" }, + { name = "s3transfer", version = "0.19.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/da/d5ca15f34a567f2d027df23395315983bd7ee35011e93c1cca12b7f89823/boto3-1.43.64.tar.gz", hash = "sha256:fc7522c3ed97d38176e0b1366406bca0fc8c888ae8d416bf953477b3f015a7b2", size = 112655, upload-time = "2026-08-04T20:12:42.45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/94/14cf3ec553edf78e5196289087ac52862c87ac179fa1cfdf1022ec366b79/boto3-1.43.64-py3-none-any.whl", hash = "sha256:2b555e63ece57cffb1ab1666fa6c23a0957e61f9d19ea8424d6c884124c3fd98", size = 140024, upload-time = "2026-08-04T20:12:41.147Z" }, +] + +[[package]] +name = "botocore" +version = "1.42.97" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9.12' and python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.9.12'", + "python_full_version <= '3.9'", +] +dependencies = [ + { name = "jmespath", marker = "python_full_version < '3.10'" }, + { name = "python-dateutil", marker = "python_full_version < '3.10'" }, + { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/95/c37edb602948fad2253ffd1bb3dba5b938645bd1845ee4160350136a0f41/botocore-1.42.97.tar.gz", hash = "sha256:5c0bb00e32d16ff6d278cc8c9e10dc3672d9c1d569031635ac3c908a60de8310", size = 15269348, upload-time = "2026-04-27T20:39:05.625Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/d2/8e025ba1a4e257879af72d06913272311af79673d82fa2581a351b924317/botocore-1.42.97-py3-none-any.whl", hash = "sha256:77d2c8ce1bc592d3fbd7c01c35836f4a5b0cac2ca03ccdf6ffc60faa16b5fadc", size = 14950367, upload-time = "2026-04-27T20:39:01.261Z" }, +] + +[[package]] +name = "botocore" +version = "1.43.64" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "jmespath", marker = "python_full_version >= '3.10'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.10'" }, + { name = "urllib3", version = "2.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/67/0e39203c5c67f750874478cf89124486044580ec5cb391eec8eb13cf25b8/botocore-1.43.64.tar.gz", hash = "sha256:a2bb131f48111094fae0a2c896b42593797e935d9c22abb2ebae4290c6dbb5ea", size = 15842274, upload-time = "2026-08-04T20:12:31.598Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/d1/f27d1a4fb571e102828a4672cb3d19e0409275eb92c701b67a85879c0b04/botocore-1.43.64-py3-none-any.whl", hash = "sha256:f0a01c47d631ab95589c244566bca971294724b21862de1c12c7c3b0de236272", size = 15525459, upload-time = "2026-08-04T20:12:28.58Z" }, +] + +[[package]] +name = "ccm" +version = "2.0.5" +source = { git = "https://github.com/scylladb/scylla-ccm.git?rev=master#ad068edc3524309b54428c1babb31ab7e7af2c89" } +dependencies = [ + { name = "boto3", version = "1.42.97", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "boto3", version = "1.43.64", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "ruamel-yaml" }, + { name = "tqdm" }, + { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "urllib3", version = "2.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9.12' and python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.9.12'", + "python_full_version <= '3.9'", +] +dependencies = [ + { name = "pycparser", version = "2.23", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cc/08ed5a43f2996a16b462f64a7055c6e962803534924b9b2f1371d8c00b7b/cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf", size = 184288, upload-time = "2025-09-08T23:23:48.404Z" }, + { url = "https://files.pythonhosted.org/packages/3d/de/38d9726324e127f727b4ecc376bc85e505bfe61ef130eaf3f290c6847dd4/cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7", size = 180509, upload-time = "2025-09-08T23:23:49.73Z" }, + { url = "https://files.pythonhosted.org/packages/9b/13/c92e36358fbcc39cf0962e83223c9522154ee8630e1df7c0b3a39a8124e2/cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c", size = 208813, upload-time = "2025-09-08T23:23:51.263Z" }, + { url = "https://files.pythonhosted.org/packages/15/12/a7a79bd0df4c3bff744b2d7e52cc1b68d5e7e427b384252c42366dc1ecbc/cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165", size = 216498, upload-time = "2025-09-08T23:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/5c51c1c7600bdd7ed9a24a203ec255dccdd0ebf4527f7b922a0bde2fb6ed/cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534", size = 203243, upload-time = "2025-09-08T23:23:53.836Z" }, + { url = "https://files.pythonhosted.org/packages/32/f2/81b63e288295928739d715d00952c8c6034cb6c6a516b17d37e0c8be5600/cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f", size = 203158, upload-time = "2025-09-08T23:23:55.169Z" }, + { url = "https://files.pythonhosted.org/packages/1f/74/cc4096ce66f5939042ae094e2e96f53426a979864aa1f96a621ad128be27/cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63", size = 216548, upload-time = "2025-09-08T23:23:56.506Z" }, + { url = "https://files.pythonhosted.org/packages/e8/be/f6424d1dc46b1091ffcc8964fa7c0ab0cd36839dd2761b49c90481a6ba1b/cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2", size = 218897, upload-time = "2025-09-08T23:23:57.825Z" }, + { url = "https://files.pythonhosted.org/packages/f7/e0/dda537c2309817edf60109e39265f24f24aa7f050767e22c98c53fe7f48b/cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65", size = 211249, upload-time = "2025-09-08T23:23:59.139Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e7/7c769804eb75e4c4b35e658dba01de1640a351a9653c3d49ca89d16ccc91/cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322", size = 218041, upload-time = "2025-09-08T23:24:00.496Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d9/6218d78f920dcd7507fc16a766b5ef8f3b913cc7aa938e7fc80b9978d089/cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a", size = 172138, upload-time = "2025-09-08T23:24:01.7Z" }, + { url = "https://files.pythonhosted.org/packages/54/8f/a1e836f82d8e32a97e6b29cc8f641779181ac7363734f12df27db803ebda/cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9", size = 182794, upload-time = "2025-09-08T23:24:02.943Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "pycparser", version = "3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/d2/2cde336b375f55c76ca670f0be3978cc048e31e24f3b4d7ce8473150a388/cffi-2.1.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be", size = 183779, upload-time = "2026-08-03T21:19:15.602Z" }, + { url = "https://files.pythonhosted.org/packages/94/1a/4b2f7c92293ba05cbd4a9a1b28faaf0326272d9488e6354657571c48a7aa/cffi-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b", size = 184178, upload-time = "2026-08-03T21:19:16.67Z" }, + { url = "https://files.pythonhosted.org/packages/17/0b/ba385d8ccedf926c3cd06e8e2f327027da5afe5f0eb30f1f7bc43ac55125/cffi-2.1.1-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004", size = 211037, upload-time = "2026-08-03T21:19:17.705Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b9/0f2e58b2cefa33255bff36935d42b13180fe559bba82596540eb404bde7d/cffi-2.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9", size = 218652, upload-time = "2026-08-03T21:19:18.735Z" }, + { url = "https://files.pythonhosted.org/packages/37/15/180e0dab27b9312c7479003d14c9e547634b7dcb934e2cc4650e1b131a7a/cffi-2.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98", size = 205422, upload-time = "2026-08-03T21:19:19.96Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/03026f0c850cbbaa9030750490225b4a7f4d524ea4df72c3cc740a90f4ef/cffi-2.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9", size = 205444, upload-time = "2026-08-03T21:19:21.246Z" }, + { url = "https://files.pythonhosted.org/packages/75/77/60bebf6f818bec84210ac5b6979ce4eeadce6fbbaabc9c7ab23e506d1ce5/cffi-2.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6", size = 218742, upload-time = "2026-08-03T21:19:22.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ae/679bf47e73fd77b352171727f07de559a003f14de5d02b904a6ec1fa73ca/cffi-2.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf", size = 221054, upload-time = "2026-08-03T21:19:23.694Z" }, + { url = "https://files.pythonhosted.org/packages/09/b8/eefc0e06913b70aa153bf74c946094a18f58fd4aff11b7f372bfdfdca050/cffi-2.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659", size = 213489, upload-time = "2026-08-03T21:19:24.922Z" }, + { url = "https://files.pythonhosted.org/packages/6f/13/4e56852824a03cdf68523a35686f1c28eacd4bd30a7b0a78e682e6e6e1d3/cffi-2.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9", size = 220241, upload-time = "2026-08-03T21:19:26.214Z" }, + { url = "https://files.pythonhosted.org/packages/99/7f/040f9e163e4acac3ee3d85b02d00b2576e7ca980d8785f0a3a5f1a9bf7f5/cffi-2.1.1-cp310-cp310-win32.whl", hash = "sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41", size = 174578, upload-time = "2026-08-03T21:19:27.338Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0b/644a2ec1a4eaba49c2939410bb1eb1d25b09d6d0582f5d2f95c537043725/cffi-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1", size = 185082, upload-time = "2026-08-03T21:19:28.409Z" }, + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/81/8e983840c6e5b93b33c2ba81aa3d52c2e42f0e9a690ce7607a2e61da4a5c/charset_normalizer-3.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a", size = 322240, upload-time = "2026-07-07T14:32:36.236Z" }, + { url = "https://files.pythonhosted.org/packages/de/d1/b4319dc3229d8272fba305e206fc0a148e2de8d4087917ce62ae6382f359/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616", size = 216475, upload-time = "2026-07-07T14:32:38.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/33/6c99c1b3e6b8bf730e1bc809b9a2608f224145069114c479a2e9e1494346/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209", size = 238670, upload-time = "2026-07-07T14:32:39.658Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f4/ffbb83546e1f198ecc70ecd372b65cf2b50f9068b380abd67640f17a8e18/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99", size = 233476, upload-time = "2026-07-07T14:32:41.155Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5f/b98b8da398637b551e427e7be922bdec19177dc54d6811dcdaa503f23aac/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8", size = 223817, upload-time = "2026-07-07T14:32:42.592Z" }, + { url = "https://files.pythonhosted.org/packages/36/31/a276bb2e66243072a3fd06fdcab9cbb61a305b02143d70d2bda21d888fa8/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b", size = 207974, upload-time = "2026-07-07T14:32:44.258Z" }, + { url = "https://files.pythonhosted.org/packages/5e/be/7ee4453d7e88dfbc4104ccd34900b9f2c7c17dac22881865fe0e82424a25/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2", size = 221655, upload-time = "2026-07-07T14:32:45.64Z" }, + { url = "https://files.pythonhosted.org/packages/1d/85/181c652953eb5276d198f375b1dd641047392050098100a3a02d6534f657/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9", size = 219229, upload-time = "2026-07-07T14:32:47.376Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e7/aaf6da33fc9f4691cda8f7efbc9f69179d3d39ec8a4799baf273ee1d8db0/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15", size = 209704, upload-time = "2026-07-07T14:32:48.855Z" }, + { url = "https://files.pythonhosted.org/packages/63/01/f2fb3bd3a73be48b173ee0c6aa8d2497af97d5663a8c4c4b491de4c62f7a/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d", size = 226243, upload-time = "2026-07-07T14:32:50.239Z" }, + { url = "https://files.pythonhosted.org/packages/c4/02/c57a22739fe05246b0b5783b3bfb6afaac4eebb46f3ececdfb2f048f780e/charset_normalizer-3.4.9-cp310-cp310-win32.whl", hash = "sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381", size = 150935, upload-time = "2026-07-07T14:32:51.676Z" }, + { url = "https://files.pythonhosted.org/packages/37/8d/ca39a7559a4797505530d084fd3a49a2c959efbbbff146302fb7be4e3b35/charset_normalizer-3.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee", size = 162314, upload-time = "2026-07-07T14:32:53.193Z" }, + { url = "https://files.pythonhosted.org/packages/01/da/a44bd7a13d426e69e4894557106cd58669097bfad4a8681123b618fbfc5d/charset_normalizer-3.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419", size = 153075, upload-time = "2026-07-07T14:32:54.554Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", size = 317075, upload-time = "2026-07-07T14:32:56.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", size = 213837, upload-time = "2026-07-07T14:32:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", size = 235503, upload-time = "2026-07-07T14:32:59.205Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", size = 229944, upload-time = "2026-07-07T14:33:00.803Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", size = 221276, upload-time = "2026-07-07T14:33:02.199Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", size = 205260, upload-time = "2026-07-07T14:33:03.698Z" }, + { url = "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", size = 217786, upload-time = "2026-07-07T14:33:05.12Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", size = 216798, upload-time = "2026-07-07T14:33:06.629Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", size = 206429, upload-time = "2026-07-07T14:33:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", size = 223066, upload-time = "2026-07-07T14:33:09.783Z" }, + { url = "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", size = 150456, upload-time = "2026-07-07T14:33:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", size = 161410, upload-time = "2026-07-07T14:33:12.743Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", size = 152649, upload-time = "2026-07-07T14:33:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ec/81e22253f4b7091eca6515bb3da5e45d05a663f7f567bb745695dc60f892/charset_normalizer-3.4.9-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:253a4a220747e8b5faf57ec320c4f5efb0cef05f647420bf267143ec15dba10a", size = 306122, upload-time = "2026-07-07T14:34:36.607Z" }, + { url = "https://files.pythonhosted.org/packages/c8/53/a8c042eb9eee4716f4d42a0f5a571eb32a09ec429be9fb0b8b9d765393ba/charset_normalizer-3.4.9-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68ce9f4d6b26d5ccbf7fd4459bf75f74a0a146677ebba80597df60cbdb20e6f4", size = 206284, upload-time = "2026-07-07T14:34:38.166Z" }, + { url = "https://files.pythonhosted.org/packages/14/cb/1db8b96547ee3186cd2dd7f2e59dd560a9b80748f3604171f3c153d62811/charset_normalizer-3.4.9-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:58150c9f9b9a552505912d182ccdf26f6396fb6094816ceebcbb20eecabaed94", size = 226837, upload-time = "2026-07-07T14:34:39.77Z" }, + { url = "https://files.pythonhosted.org/packages/6a/05/c94d5cd23396289c54c93b02e0273b4dd8921641d9968c4828caf9bbaad9/charset_normalizer-3.4.9-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df7276909358e5635ae203673ab7e509ddd224225a8d6b0790bf13eb2bde1cc5", size = 222199, upload-time = "2026-07-07T14:34:41.391Z" }, + { url = "https://files.pythonhosted.org/packages/6d/46/79847edd07244a4a2d443c6655a7b6ee94203c21539414b059f32713c357/charset_normalizer-3.4.9-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c09a49d6cde137258beb3d551994a2927fd35ad5cf96aed573f61bbd67c5f84", size = 214344, upload-time = "2026-07-07T14:34:42.986Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b4/ef5a49b2e77c00deb43bb3256592b115ba9e4346016e82c516b8d215bf68/charset_normalizer-3.4.9-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:231ddcbb35e2ff8973e1365db41fe0572662893b99a05deb183b68ad4c0c8bd4", size = 199988, upload-time = "2026-07-07T14:34:44.685Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ca/ad1d7c7d3077dab873f539d3e1d083c0845a762cb0bafdfbe3ef93add598/charset_normalizer-3.4.9-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:920079c3f7456fa213e0829ed2073aaa727fd39d889ead5b4f35d0de5460d04f", size = 211908, upload-time = "2026-07-07T14:34:46.227Z" }, + { url = "https://files.pythonhosted.org/packages/ed/61/710738687f90d01c06a04ed52d6ca1e62dd9b1d8cc2567098167c4691034/charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0fa1aec2d32bcc03c8fa0f6f1712caad1adc38509f31142112e5c9daf5b9c833", size = 209320, upload-time = "2026-07-07T14:34:47.753Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c0/6eec7bdabe6cbbcc274ec04596f6d93865751a0541d33d60d1ce179bd372/charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ad41ba96094304aa090f5a30cb6e4fb3b3f1c264c523394b4c39bbacc4dc92ba", size = 200980, upload-time = "2026-07-07T14:34:49.362Z" }, + { url = "https://files.pythonhosted.org/packages/eb/78/59344ff9a4a7b5f6530bf7bec2c980047cc42c3a616596cdbd8cb5c1a1af/charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:43b9e366a31fdd1c87d0eb08f579b4a82b723ea54338f040d6b4e518a026ea29", size = 216545, upload-time = "2026-07-07T14:34:50.98Z" }, + { url = "https://files.pythonhosted.org/packages/17/6d/bff78a4bacc4891bc63ec5bdc6776d8c85e47fab93d0d5f6223068fad0a4/charset_normalizer-3.4.9-cp39-cp39-win32.whl", hash = "sha256:93d59d504b230e83c7a843251681959a0b6a9cd76f6e146ce1b8a80eb8739af9", size = 146256, upload-time = "2026-07-07T14:34:52.509Z" }, + { url = "https://files.pythonhosted.org/packages/a2/55/86048bde1c9d0352940bd7b87d825091a52aef67d01cde6c6f7342c5b552/charset_normalizer-3.4.9-cp39-cp39-win_amd64.whl", hash = "sha256:ddf4af30b417d9fe16481e9b81c27ab2a7cde1ff7ba3e85653b02db7d145dc7b", size = 156413, upload-time = "2026-07-07T14:34:54.117Z" }, + { url = "https://files.pythonhosted.org/packages/28/e9/9fb6099b868c82a40698a748ae0fbd4f31ccc13844c176a07158ba2abbfd/charset_normalizer-3.4.9-cp39-cp39-win_arm64.whl", hash = "sha256:476743fe6dfe14a2da12e3ac79125dc84a3b2cf8094369a47a1529b0cd8549fe", size = 147887, upload-time = "2026-07-07T14:34:55.51Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9.12' and python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.9.12'", + "python_full_version <= '3.9'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "constantly" +version = "23.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/6f/cb2a94494ff74aa9528a36c5b1422756330a75a8367bf20bd63171fc324d/constantly-23.10.4.tar.gz", hash = "sha256:aa92b70a33e2ac0bb33cd745eb61776594dc48764b06c35e0efd050b7f1c7cbd", size = 13300, upload-time = "2023-10-28T23:18:24.316Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/40/c199d095151addf69efdb4b9ca3a4f20f70e20508d6222bffb9b76f58573/constantly-23.10.4-py3-none-any.whl", hash = "sha256:3fd9b4d1c3dc1ec9757f3c52aef7e53ad9323dbe39f51dfd4c43853b68dfa3f9", size = 13547, upload-time = "2023-10-28T23:18:23.038Z" }, +] + +[[package]] +name = "coverage" +version = "7.10.7" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9.12' and python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.9.12'", + "python_full_version <= '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6c/3a3f7a46888e69d18abe3ccc6fe4cb16cccb1e6a2f99698931dafca489e6/coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a", size = 217987, upload-time = "2025-09-21T20:00:57.218Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/952d30f180b1a916c11a56f5c22d3535e943aa22430e9e3322447e520e1c/coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5", size = 218388, upload-time = "2025-09-21T20:01:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/50/2b/9e0cf8ded1e114bcd8b2fd42792b57f1c4e9e4ea1824cde2af93a67305be/coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17", size = 245148, upload-time = "2025-09-21T20:01:01.768Z" }, + { url = "https://files.pythonhosted.org/packages/19/20/d0384ac06a6f908783d9b6aa6135e41b093971499ec488e47279f5b846e6/coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b", size = 246958, upload-time = "2025-09-21T20:01:03.355Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/5c283cff3d41285f8eab897651585db908a909c572bdc014bcfaf8a8b6ae/coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87", size = 248819, upload-time = "2025-09-21T20:01:04.968Z" }, + { url = "https://files.pythonhosted.org/packages/60/22/02eb98fdc5ff79f423e990d877693e5310ae1eab6cb20ae0b0b9ac45b23b/coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e", size = 245754, upload-time = "2025-09-21T20:01:06.321Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bc/25c83bcf3ad141b32cd7dc45485ef3c01a776ca3aa8ef0a93e77e8b5bc43/coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e", size = 246860, upload-time = "2025-09-21T20:01:07.605Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/95574702888b58c0928a6e982038c596f9c34d52c5e5107f1eef729399b5/coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df", size = 244877, upload-time = "2025-09-21T20:01:08.829Z" }, + { url = "https://files.pythonhosted.org/packages/47/b6/40095c185f235e085df0e0b158f6bd68cc6e1d80ba6c7721dc81d97ec318/coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0", size = 245108, upload-time = "2025-09-21T20:01:10.527Z" }, + { url = "https://files.pythonhosted.org/packages/c8/50/4aea0556da7a4b93ec9168420d170b55e2eb50ae21b25062513d020c6861/coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13", size = 245752, upload-time = "2025-09-21T20:01:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/6a/28/ea1a84a60828177ae3b100cb6723838523369a44ec5742313ed7db3da160/coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b", size = 220497, upload-time = "2025-09-21T20:01:13.459Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1a/a81d46bbeb3c3fd97b9602ebaa411e076219a150489bcc2c025f151bd52d/coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807", size = 221392, upload-time = "2025-09-21T20:01:14.722Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5d/c1a17867b0456f2e9ce2d8d4708a4c3a089947d0bec9c66cdf60c9e7739f/coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59", size = 218102, upload-time = "2025-09-21T20:01:16.089Z" }, + { url = "https://files.pythonhosted.org/packages/54/f0/514dcf4b4e3698b9a9077f084429681bf3aad2b4a72578f89d7f643eb506/coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a", size = 218505, upload-time = "2025-09-21T20:01:17.788Z" }, + { url = "https://files.pythonhosted.org/packages/20/f6/9626b81d17e2a4b25c63ac1b425ff307ecdeef03d67c9a147673ae40dc36/coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699", size = 248898, upload-time = "2025-09-21T20:01:19.488Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ef/bd8e719c2f7417ba03239052e099b76ea1130ac0cbb183ee1fcaa58aaff3/coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d", size = 250831, upload-time = "2025-09-21T20:01:20.817Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b6/bf054de41ec948b151ae2b79a55c107f5760979538f5fb80c195f2517718/coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e", size = 252937, upload-time = "2025-09-21T20:01:22.171Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e5/3860756aa6f9318227443c6ce4ed7bf9e70bb7f1447a0353f45ac5c7974b/coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23", size = 249021, upload-time = "2025-09-21T20:01:23.907Z" }, + { url = "https://files.pythonhosted.org/packages/26/0f/bd08bd042854f7fd07b45808927ebcce99a7ed0f2f412d11629883517ac2/coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab", size = 250626, upload-time = "2025-09-21T20:01:25.721Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a7/4777b14de4abcc2e80c6b1d430f5d51eb18ed1d75fca56cbce5f2db9b36e/coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82", size = 248682, upload-time = "2025-09-21T20:01:27.105Z" }, + { url = "https://files.pythonhosted.org/packages/34/72/17d082b00b53cd45679bad682fac058b87f011fd8b9fe31d77f5f8d3a4e4/coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2", size = 248402, upload-time = "2025-09-21T20:01:28.629Z" }, + { url = "https://files.pythonhosted.org/packages/81/7a/92367572eb5bdd6a84bfa278cc7e97db192f9f45b28c94a9ca1a921c3577/coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61", size = 249320, upload-time = "2025-09-21T20:01:30.004Z" }, + { url = "https://files.pythonhosted.org/packages/2f/88/a23cc185f6a805dfc4fdf14a94016835eeb85e22ac3a0e66d5e89acd6462/coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14", size = 220536, upload-time = "2025-09-21T20:01:32.184Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ef/0b510a399dfca17cec7bc2f05ad8bd78cf55f15c8bc9a73ab20c5c913c2e/coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2", size = 221425, upload-time = "2025-09-21T20:01:33.557Z" }, + { url = "https://files.pythonhosted.org/packages/51/7f/023657f301a276e4ba1850f82749bc136f5a7e8768060c2e5d9744a22951/coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a", size = 220103, upload-time = "2025-09-21T20:01:34.929Z" }, + { url = "https://files.pythonhosted.org/packages/13/e4/eb12450f71b542a53972d19117ea5a5cea1cab3ac9e31b0b5d498df1bd5a/coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417", size = 218290, upload-time = "2025-09-21T20:01:36.455Z" }, + { url = "https://files.pythonhosted.org/packages/37/66/593f9be12fc19fb36711f19a5371af79a718537204d16ea1d36f16bd78d2/coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973", size = 218515, upload-time = "2025-09-21T20:01:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/4c49f7ae09cafdacc73fbc30949ffe77359635c168f4e9ff33c9ebb07838/coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c", size = 250020, upload-time = "2025-09-21T20:01:39.617Z" }, + { url = "https://files.pythonhosted.org/packages/a6/90/a64aaacab3b37a17aaedd83e8000142561a29eb262cede42d94a67f7556b/coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7", size = 252769, upload-time = "2025-09-21T20:01:41.341Z" }, + { url = "https://files.pythonhosted.org/packages/98/2e/2dda59afd6103b342e096f246ebc5f87a3363b5412609946c120f4e7750d/coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6", size = 253901, upload-time = "2025-09-21T20:01:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/dc/8d8119c9051d50f3119bb4a75f29f1e4a6ab9415cd1fa8bf22fcc3fb3b5f/coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59", size = 250413, upload-time = "2025-09-21T20:01:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/98/b3/edaff9c5d79ee4d4b6d3fe046f2b1d799850425695b789d491a64225d493/coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b", size = 251820, upload-time = "2025-09-21T20:01:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/11/25/9a0728564bb05863f7e513e5a594fe5ffef091b325437f5430e8cfb0d530/coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a", size = 249941, upload-time = "2025-09-21T20:01:47.296Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fd/ca2650443bfbef5b0e74373aac4df67b08180d2f184b482c41499668e258/coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb", size = 249519, upload-time = "2025-09-21T20:01:48.73Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/f692f125fb4299b6f963b0745124998ebb8e73ecdfce4ceceb06a8c6bec5/coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1", size = 251375, upload-time = "2025-09-21T20:01:50.529Z" }, + { url = "https://files.pythonhosted.org/packages/5e/75/61b9bbd6c7d24d896bfeec57acba78e0f8deac68e6baf2d4804f7aae1f88/coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256", size = 220699, upload-time = "2025-09-21T20:01:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f3/3bf7905288b45b075918d372498f1cf845b5b579b723c8fd17168018d5f5/coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba", size = 221512, upload-time = "2025-09-21T20:01:53.481Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/3e32dbe933979d05cf2dac5e697c8599cfe038aaf51223ab901e208d5a62/coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf", size = 220147, upload-time = "2025-09-21T20:01:55.2Z" }, + { url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320, upload-time = "2025-09-21T20:01:56.629Z" }, + { url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575, upload-time = "2025-09-21T20:01:58.203Z" }, + { url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568, upload-time = "2025-09-21T20:01:59.748Z" }, + { url = "https://files.pythonhosted.org/packages/a2/77/8c6d22bf61921a59bce5471c2f1f7ac30cd4ac50aadde72b8c48d5727902/coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b", size = 252174, upload-time = "2025-09-21T20:02:01.192Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/b6ea4f69bbb52dac0aebd62157ba6a9dddbfe664f5af8122dac296c3ee15/coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49", size = 253447, upload-time = "2025-09-21T20:02:02.701Z" }, + { url = "https://files.pythonhosted.org/packages/f9/28/4831523ba483a7f90f7b259d2018fef02cb4d5b90bc7c1505d6e5a84883c/coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911", size = 249779, upload-time = "2025-09-21T20:02:04.185Z" }, + { url = "https://files.pythonhosted.org/packages/a7/9f/4331142bc98c10ca6436d2d620c3e165f31e6c58d43479985afce6f3191c/coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0", size = 251604, upload-time = "2025-09-21T20:02:06.034Z" }, + { url = "https://files.pythonhosted.org/packages/ce/60/bda83b96602036b77ecf34e6393a3836365481b69f7ed7079ab85048202b/coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f", size = 249497, upload-time = "2025-09-21T20:02:07.619Z" }, + { url = "https://files.pythonhosted.org/packages/5f/af/152633ff35b2af63977edd835d8e6430f0caef27d171edf2fc76c270ef31/coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c", size = 249350, upload-time = "2025-09-21T20:02:10.34Z" }, + { url = "https://files.pythonhosted.org/packages/9d/71/d92105d122bd21cebba877228990e1646d862e34a98bb3374d3fece5a794/coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f", size = 251111, upload-time = "2025-09-21T20:02:12.122Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746, upload-time = "2025-09-21T20:02:13.919Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541, upload-time = "2025-09-21T20:02:15.57Z" }, + { url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170, upload-time = "2025-09-21T20:02:17.395Z" }, + { url = "https://files.pythonhosted.org/packages/bb/22/e04514bf2a735d8b0add31d2b4ab636fc02370730787c576bb995390d2d5/coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c", size = 219029, upload-time = "2025-09-21T20:02:18.936Z" }, + { url = "https://files.pythonhosted.org/packages/11/0b/91128e099035ece15da3445d9015e4b4153a6059403452d324cbb0a575fa/coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15", size = 219259, upload-time = "2025-09-21T20:02:20.44Z" }, + { url = "https://files.pythonhosted.org/packages/8b/51/66420081e72801536a091a0c8f8c1f88a5c4bf7b9b1bdc6222c7afe6dc9b/coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4", size = 260592, upload-time = "2025-09-21T20:02:22.313Z" }, + { url = "https://files.pythonhosted.org/packages/5d/22/9b8d458c2881b22df3db5bb3e7369e63d527d986decb6c11a591ba2364f7/coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0", size = 262768, upload-time = "2025-09-21T20:02:24.287Z" }, + { url = "https://files.pythonhosted.org/packages/f7/08/16bee2c433e60913c610ea200b276e8eeef084b0d200bdcff69920bd5828/coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0", size = 264995, upload-time = "2025-09-21T20:02:26.133Z" }, + { url = "https://files.pythonhosted.org/packages/20/9d/e53eb9771d154859b084b90201e5221bca7674ba449a17c101a5031d4054/coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65", size = 259546, upload-time = "2025-09-21T20:02:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b0/69bc7050f8d4e56a89fb550a1577d5d0d1db2278106f6f626464067b3817/coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541", size = 262544, upload-time = "2025-09-21T20:02:29.216Z" }, + { url = "https://files.pythonhosted.org/packages/ef/4b/2514b060dbd1bc0aaf23b852c14bb5818f244c664cb16517feff6bb3a5ab/coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6", size = 260308, upload-time = "2025-09-21T20:02:31.226Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/7ba2175007c246d75e496f64c06e94122bdb914790a1285d627a918bd271/coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999", size = 258920, upload-time = "2025-09-21T20:02:32.823Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/fac9f7abbc841409b9a410309d73bfa6cfb2e51c3fada738cb607ce174f8/coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2", size = 261434, upload-time = "2025-09-21T20:02:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403, upload-time = "2025-09-21T20:02:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469, upload-time = "2025-09-21T20:02:39.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731, upload-time = "2025-09-21T20:02:40.939Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/5844ab4ca6a4dd97a1850e030a15ec7d292b5c5cb93082979225126e35dd/coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520", size = 218302, upload-time = "2025-09-21T20:02:42.527Z" }, + { url = "https://files.pythonhosted.org/packages/f0/89/673f6514b0961d1f0e20ddc242e9342f6da21eaba3489901b565c0689f34/coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32", size = 218578, upload-time = "2025-09-21T20:02:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/05/e8/261cae479e85232828fb17ad536765c88dd818c8470aca690b0ac6feeaa3/coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f", size = 249629, upload-time = "2025-09-21T20:02:46.503Z" }, + { url = "https://files.pythonhosted.org/packages/82/62/14ed6546d0207e6eda876434e3e8475a3e9adbe32110ce896c9e0c06bb9a/coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a", size = 252162, upload-time = "2025-09-21T20:02:48.689Z" }, + { url = "https://files.pythonhosted.org/packages/ff/49/07f00db9ac6478e4358165a08fb41b469a1b053212e8a00cb02f0d27a05f/coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360", size = 253517, upload-time = "2025-09-21T20:02:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/a2/59/c5201c62dbf165dfbc91460f6dbbaa85a8b82cfa6131ac45d6c1bfb52deb/coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69", size = 249632, upload-time = "2025-09-21T20:02:51.971Z" }, + { url = "https://files.pythonhosted.org/packages/07/ae/5920097195291a51fb00b3a70b9bbd2edbfe3c84876a1762bd1ef1565ebc/coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14", size = 251520, upload-time = "2025-09-21T20:02:53.858Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3c/a815dde77a2981f5743a60b63df31cb322c944843e57dbd579326625a413/coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe", size = 249455, upload-time = "2025-09-21T20:02:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/aa/99/f5cdd8421ea656abefb6c0ce92556709db2265c41e8f9fc6c8ae0f7824c9/coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e", size = 249287, upload-time = "2025-09-21T20:02:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/c3/7a/e9a2da6a1fc5d007dd51fca083a663ab930a8c4d149c087732a5dbaa0029/coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd", size = 250946, upload-time = "2025-09-21T20:02:59.431Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009, upload-time = "2025-09-21T20:03:01.324Z" }, + { url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804, upload-time = "2025-09-21T20:03:03.4Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384, upload-time = "2025-09-21T20:03:05.111Z" }, + { url = "https://files.pythonhosted.org/packages/62/09/9a5608d319fa3eba7a2019addeacb8c746fb50872b57a724c9f79f146969/coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63", size = 219047, upload-time = "2025-09-21T20:03:06.795Z" }, + { url = "https://files.pythonhosted.org/packages/f5/6f/f58d46f33db9f2e3647b2d0764704548c184e6f5e014bef528b7f979ef84/coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2", size = 219266, upload-time = "2025-09-21T20:03:08.495Z" }, + { url = "https://files.pythonhosted.org/packages/74/5c/183ffc817ba68e0b443b8c934c8795553eb0c14573813415bd59941ee165/coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d", size = 260767, upload-time = "2025-09-21T20:03:10.172Z" }, + { url = "https://files.pythonhosted.org/packages/0f/48/71a8abe9c1ad7e97548835e3cc1adbf361e743e9d60310c5f75c9e7bf847/coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0", size = 262931, upload-time = "2025-09-21T20:03:11.861Z" }, + { url = "https://files.pythonhosted.org/packages/84/fd/193a8fb132acfc0a901f72020e54be5e48021e1575bb327d8ee1097a28fd/coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699", size = 265186, upload-time = "2025-09-21T20:03:13.539Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8f/74ecc30607dd95ad50e3034221113ccb1c6d4e8085cc761134782995daae/coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9", size = 259470, upload-time = "2025-09-21T20:03:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/0f/55/79ff53a769f20d71b07023ea115c9167c0bb56f281320520cf64c5298a96/coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f", size = 262626, upload-time = "2025-09-21T20:03:17.673Z" }, + { url = "https://files.pythonhosted.org/packages/88/e2/dac66c140009b61ac3fc13af673a574b00c16efdf04f9b5c740703e953c0/coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1", size = 260386, upload-time = "2025-09-21T20:03:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f1/f48f645e3f33bb9ca8a496bc4a9671b52f2f353146233ebd7c1df6160440/coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0", size = 258852, upload-time = "2025-09-21T20:03:21.007Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3b/8442618972c51a7affeead957995cfa8323c0c9bcf8fa5a027421f720ff4/coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399", size = 261534, upload-time = "2025-09-21T20:03:23.12Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784, upload-time = "2025-09-21T20:03:24.769Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905, upload-time = "2025-09-21T20:03:26.93Z" }, + { url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922, upload-time = "2025-09-21T20:03:28.672Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/d1c25053764b4c42eb294aae92ab617d2e4f803397f9c7c8295caa77a260/coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3", size = 217978, upload-time = "2025-09-21T20:03:30.362Z" }, + { url = "https://files.pythonhosted.org/packages/52/2f/b9f9daa39b80ece0b9548bbb723381e29bc664822d9a12c2135f8922c22b/coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c", size = 218370, upload-time = "2025-09-21T20:03:32.147Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6e/30d006c3b469e58449650642383dddf1c8fb63d44fdf92994bfd46570695/coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396", size = 244802, upload-time = "2025-09-21T20:03:33.919Z" }, + { url = "https://files.pythonhosted.org/packages/b0/49/8a070782ce7e6b94ff6a0b6d7c65ba6bc3091d92a92cef4cd4eb0767965c/coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40", size = 246625, upload-time = "2025-09-21T20:03:36.09Z" }, + { url = "https://files.pythonhosted.org/packages/6a/92/1c1c5a9e8677ce56d42b97bdaca337b2d4d9ebe703d8c174ede52dbabd5f/coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594", size = 248399, upload-time = "2025-09-21T20:03:38.342Z" }, + { url = "https://files.pythonhosted.org/packages/c0/54/b140edee7257e815de7426d5d9846b58505dffc29795fff2dfb7f8a1c5a0/coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a", size = 245142, upload-time = "2025-09-21T20:03:40.591Z" }, + { url = "https://files.pythonhosted.org/packages/e4/9e/6d6b8295940b118e8b7083b29226c71f6154f7ff41e9ca431f03de2eac0d/coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b", size = 246284, upload-time = "2025-09-21T20:03:42.355Z" }, + { url = "https://files.pythonhosted.org/packages/db/e5/5e957ca747d43dbe4d9714358375c7546cb3cb533007b6813fc20fce37ad/coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3", size = 244353, upload-time = "2025-09-21T20:03:44.218Z" }, + { url = "https://files.pythonhosted.org/packages/9a/45/540fc5cc92536a1b783b7ef99450bd55a4b3af234aae35a18a339973ce30/coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0", size = 244430, upload-time = "2025-09-21T20:03:46.065Z" }, + { url = "https://files.pythonhosted.org/packages/75/0b/8287b2e5b38c8fe15d7e3398849bb58d382aedc0864ea0fa1820e8630491/coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f", size = 245311, upload-time = "2025-09-21T20:03:48.19Z" }, + { url = "https://files.pythonhosted.org/packages/0c/1d/29724999984740f0c86d03e6420b942439bf5bd7f54d4382cae386a9d1e9/coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431", size = 220500, upload-time = "2025-09-21T20:03:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/43/11/4b1e6b129943f905ca54c339f343877b55b365ae2558806c1be4f7476ed5/coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07", size = 221408, upload-time = "2025-09-21T20:03:51.803Z" }, + { url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version < '3.10'" }, +] + +[[package]] +name = "coverage" +version = "7.15.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/45/78dbf9604ee5b3db24efbf26bed1cb58862fb40480cba821963c69348751/coverage-7.15.3.tar.gz", hash = "sha256:ae7ea5a4614acf399ef0483c4cb34f8f8f01df848d8fcbe7d3ce0865733f1c4d", size = 935592, upload-time = "2026-08-02T18:50:17.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/d9/01d8e19b2c0e55903bfb540c9f6bd32326f1d5b2fcb5a7dd8648ae2dd9c5/coverage-7.15.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3a82b2ceee91ba353e59fe2436d8a9eae799ff9825e5385423ea205d693e2949", size = 222202, upload-time = "2026-08-02T18:47:25.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/92/1c23aeb83c7239af07061abc6e96f00f9b62deec8fae022cab1b353e6d46/coverage-7.15.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3088cce65e54c2eefc08e7e1ca0b0acec1e95e8cf084ac848599103ed0367f74", size = 222723, upload-time = "2026-08-02T18:47:28.359Z" }, + { url = "https://files.pythonhosted.org/packages/b9/76/186f60bae815941553b70877d814c45994db8198bb76933bd062c18ee437/coverage-7.15.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a65e09efb0b5ab21fc54a8a65c5b2e533c0a4c0d064af0259a005dc656dc1b13", size = 249461, upload-time = "2026-08-02T18:47:29.802Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/53e010accfea3340905c5bb9207a2e461bba9b372621f1b88c1bd0e1392a/coverage-7.15.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b51f279a2477b0e1f288b98f141fd227acfdd1d3f0370400e473788879b47871", size = 251290, upload-time = "2026-08-02T18:47:31.445Z" }, + { url = "https://files.pythonhosted.org/packages/5b/13/d916056137fb6969e9d9f58ee11d1ef56778673843828315030733a3a0b6/coverage-7.15.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7835176988cbcf1f014db683bc33aa15e0558e412bf08deaa99757335b88df15", size = 253156, upload-time = "2026-08-02T18:47:33.033Z" }, + { url = "https://files.pythonhosted.org/packages/cf/72/0a4198d82e765f3351a91714d42526eb765e5c97776f7674489acbe7d062/coverage-7.15.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f24896dc8863167f6732f4142f5d37e6195eccc8fe5fe528d35d49597d29fdb3", size = 255068, upload-time = "2026-08-02T18:47:34.852Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/ebe4e0751e3637d87162887d0d3cdf4716f96782ab6face09a295e74cba4/coverage-7.15.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9490d43e5d041fdf376770a886a29722adb05f6b9c21a65c48c81fc8f1c33fd7", size = 250142, upload-time = "2026-08-02T18:47:36.588Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a2/12977c74fcf92f9b1da45fb9576c5f593a2c47bde04e937a8bb32dd56bfa/coverage-7.15.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:225e359bd5dedaff6d68e36091af20555866c557d968167308b677379bf575c3", size = 251195, upload-time = "2026-08-02T18:47:38.168Z" }, + { url = "https://files.pythonhosted.org/packages/2f/c8/42bd9aa40386c0fbcc7af221ab4737dad10d27bb4971ca2783676619a79b/coverage-7.15.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:22119e2e3b2ac5ac024d50131fdd4b22ab4c6cf8aa2fc792cce73c0d94c5812d", size = 249200, upload-time = "2026-08-02T18:47:39.773Z" }, + { url = "https://files.pythonhosted.org/packages/0a/52/f1ce0dd8a2ec5c3911f1bc98b859be09cc4bbd705ed74ceb905729837c79/coverage-7.15.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:12d555badc462b0f6037ce8bec8b4af8d71f90eb55b57d0a358731f7ee7883e2", size = 253013, upload-time = "2026-08-02T18:47:41.372Z" }, + { url = "https://files.pythonhosted.org/packages/fd/2c/9a642c4cf7b6992b2eba75359b6cb548bd437001d6083fd0ffe492b80d38/coverage-7.15.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c4e2cf9cf774939b3dc581c6e31dfe7e8d7608b24f0f17524d6161f8235c3d2c", size = 249470, upload-time = "2026-08-02T18:47:42.997Z" }, + { url = "https://files.pythonhosted.org/packages/89/32/271d85639ac5de099046f7418e850047b1e964f893535128997b5cddde8d/coverage-7.15.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cea1b3e19d710f67e2ba9ce0b0b51032c2a9b4808a65ced48ddf336ef7e58058", size = 250073, upload-time = "2026-08-02T18:47:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/15/71/6216430095c5437f83d7bfa7c1adb0965e26ada88d9fff49bf55e2cab154/coverage-7.15.3-cp310-cp310-win32.whl", hash = "sha256:25c77560309f157e7b7ee8fe0bf78d047ba900b7ae42f0e50e559305b366fea2", size = 224263, upload-time = "2026-08-02T18:47:46.078Z" }, + { url = "https://files.pythonhosted.org/packages/53/8a/f1032fb2714c28fedf00d73562c4bb9f713fa8a90593ed577bbb708a7de1/coverage-7.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:179fbf847e6c3d90ea71bfd570fe57f1ddb1c51474754894871c1e11099efaa0", size = 224886, upload-time = "2026-08-02T18:47:47.668Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9c/c8a3a923c24f631695cea2d5e2f02e776bc0af6e03800626e13a6c05a615/coverage-7.15.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5f3f854ab4599d98f7799ac9b91e34e8ec9ebc9a6372ee8c1f3413a68cc8b5e9", size = 222328, upload-time = "2026-08-02T18:47:49.228Z" }, + { url = "https://files.pythonhosted.org/packages/92/51/dda77f34cbd2513d6ffb898c901d19e9ca55f48c0cbc4a1eb173a97d157a/coverage-7.15.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75268348fee1f199653b8a846262aec5581c6bb008c4f58824959fb708cc688f", size = 222832, upload-time = "2026-08-02T18:47:51.219Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/e0faafc4c6e23bd76c76148875ee9ec5781b8f1cd62cea2bc4ca0f0f0e5d/coverage-7.15.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:21081739f6264cc594cad2d42b62befbd17633824022866c68720eb0c4b8d6b4", size = 253250, upload-time = "2026-08-02T18:47:52.737Z" }, + { url = "https://files.pythonhosted.org/packages/14/e2/4b1e0eeb727ffb471e411c1bd3402184b5dd54a77a762b0e55e87cdf9ae3/coverage-7.15.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:718d366251b060c10731c7dd359de6caea72250036eb94576aa56dacbf830a11", size = 255160, upload-time = "2026-08-02T18:47:54.404Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/a602d2d48f9db9f795e578a86aa914f7b20008e9330902defcfb73d17b3a/coverage-7.15.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fa1bbaa502a6e877f3ee67cbac3eba2bb637f623e454e6c37b81b38896dbd48f", size = 257269, upload-time = "2026-08-02T18:47:56.157Z" }, + { url = "https://files.pythonhosted.org/packages/22/fa/bf6db13df2fcee00d2671849fe58c99232ee79a01fec7478c2bf7839b9e1/coverage-7.15.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:494880c9e60782610683f4eb9b65cce4f886673596b8f3cb2dfa079fc551c743", size = 259231, upload-time = "2026-08-02T18:47:57.76Z" }, + { url = "https://files.pythonhosted.org/packages/89/37/8118f13b17fa7d9a3aa2c301d93f2d5ffeef70fa7e27e639a74bdacd3fea/coverage-7.15.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3db264ea689f9e8f9fa4fb9005fee4048c3bff4a547f4cfa27f5086cb0804ec0", size = 253357, upload-time = "2026-08-02T18:47:59.261Z" }, + { url = "https://files.pythonhosted.org/packages/97/6d/c7b94fb03962f4d6f0fe13d01c4eb9c4c6e2e714a20d074516ec7582b110/coverage-7.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4e869d4799674d67778e76ddbe2e26cf1673369262e231a8ec259421b1015fea", size = 254961, upload-time = "2026-08-02T18:48:00.901Z" }, + { url = "https://files.pythonhosted.org/packages/87/f9/fe0bd415fa56e36b62b649017c8fc98330858be4c7593789efb78cd24178/coverage-7.15.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:696fc7a28bbf717aba8d2c6963d26702945c7832cb313ba3b323aa5b1afb3156", size = 253024, upload-time = "2026-08-02T18:48:02.745Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7c/ffa53506d63ba8a77f5b9557dd6f5a5a5ad85adc680d7857410138f82bd9/coverage-7.15.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:3fe9be1c527497d047f770d88a0110189714c36383bb88384508f750c302bffa", size = 256792, upload-time = "2026-08-02T18:48:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/1f/c6/df42458e72c18a49fe87e40ccd3fb0314210915256cf4a5593e1b3250e04/coverage-7.15.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2400591f4b2e33746c70846388f8bb4c7e33b820e31cb8c6cb2f25305310438b", size = 252744, upload-time = "2026-08-02T18:48:06.154Z" }, + { url = "https://files.pythonhosted.org/packages/f1/14/8bf18a4b10a44f8ba5f604b00e102f37daf49d581d66a37dc33fa267e1a6/coverage-7.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2e557178799282269412a672e5753f2179edfe1b3f0f19b0c98f8e72d482326a", size = 253652, upload-time = "2026-08-02T18:48:07.955Z" }, + { url = "https://files.pythonhosted.org/packages/27/e6/e530c9bb94e4155817cbd149034105b062a6913bc356ae08f454d155de53/coverage-7.15.3-cp311-cp311-win32.whl", hash = "sha256:68ea6c947375982ae907e19e9d2ef156bd6e68e11f3566dd568d7f4ec974e715", size = 224428, upload-time = "2026-08-02T18:48:09.845Z" }, + { url = "https://files.pythonhosted.org/packages/b4/98/0050c692d120988f1973a15196f52dee4ae221848b760281461a2005b613/coverage-7.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:28743dad31622e8c474b17446118037361f5b1f4f2ecdf72d4f6fde246d64446", size = 224906, upload-time = "2026-08-02T18:48:11.611Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ae/c0ef3e2ba3f35fc1c6985811a40edd9331e5b8978c9ecf84699de3edacbe/coverage-7.15.3-cp311-cp311-win_arm64.whl", hash = "sha256:c4398918c4fda32718191239e451fd86ac5ad1e8979b592f1921ee2d1f038965", size = 224448, upload-time = "2026-08-02T18:48:13.304Z" }, + { url = "https://files.pythonhosted.org/packages/d1/6c/bac99d9d4c6abe856e93bf3f5212982ac0bfac126dd4a042753bd53bc5af/coverage-7.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:79a3e32e83227d83d9684459ed579769b56c369ac2d7313099b2d9e031d2e10f", size = 222499, upload-time = "2026-08-02T18:48:15.018Z" }, + { url = "https://files.pythonhosted.org/packages/aa/bc/cb9a39b083bc1aa70586482dab25c9be20bab0ec6c155340e50d9066bb1e/coverage-7.15.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:767feb87c5886d781d0a69fafd450a20826ddab7b79bce1665deb64d21441b60", size = 222866, upload-time = "2026-08-02T18:48:16.884Z" }, + { url = "https://files.pythonhosted.org/packages/58/fb/beaa453d62000a0a5b39838bee2a137afe609a50a71f55e83c73461e513b/coverage-7.15.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:50951e37033c40548d777b8a8454a2cd622dba1136780065678dccaec307c47f", size = 254367, upload-time = "2026-08-02T18:48:18.507Z" }, + { url = "https://files.pythonhosted.org/packages/66/64/43e72500ed6815cef189f9193f29d7af4b078830337c95ea976cd0c0d427/coverage-7.15.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:63a4ff67364afb2cac826b8bbd78a5c50ce656a7b7137436b44d7b96a9271088", size = 257103, upload-time = "2026-08-02T18:48:20.172Z" }, + { url = "https://files.pythonhosted.org/packages/66/3a/2893e2937adfe02f45fd38e4a8a0a0d8b7a02ff9e012ac3d009bee3c4f16/coverage-7.15.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e95e42856509675fe26560310313a6117640e96f9a1e19bb3d220116a27c94c", size = 258220, upload-time = "2026-08-02T18:48:21.963Z" }, + { url = "https://files.pythonhosted.org/packages/30/b4/d5e6e2eb1a62961083734291304b1f85df72e2abe95c76eb88a7f472afd0/coverage-7.15.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:abad631cba27094b4631993f4c72e89ac0ca1b3a0236c7abaf8ca79aea619851", size = 260481, upload-time = "2026-08-02T18:48:23.682Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/9b72c5c6a9798a9a12cf65f66e077cc1fdd396e61915c862688f9afe1cae/coverage-7.15.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b0807f1f051dd82a234ad6acdb6f1425baede60be1e84e862496c8cc9262ab9", size = 254749, upload-time = "2026-08-02T18:48:25.32Z" }, + { url = "https://files.pythonhosted.org/packages/92/20/e1c2f759e2dbce559ba85c40c0e4acfecc6cff4b740c294c88e41ccc6111/coverage-7.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8d6df7aeb5bc464040bbc9ae173d875785d3677ebc4307817997d622d74225e", size = 256138, upload-time = "2026-08-02T18:48:27.064Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ab/48cc7e760f769e86ae290a125ea6e7209dfbdbbbb7ff4f5d9d1ee7a45d57/coverage-7.15.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:974471c506c9f5758808b47c1ebf7949ecd0848f5c1020e78675fefe5ff46866", size = 254283, upload-time = "2026-08-02T18:48:29.082Z" }, + { url = "https://files.pythonhosted.org/packages/15/26/39529a68154f99b3a1829debd8b25eac384effeec890a293b5bbdcb49186/coverage-7.15.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5cba0c9c13e35c86df7998f1afaf6b1da224a3a39e4da59bdabf60c148046dcb", size = 258352, upload-time = "2026-08-02T18:48:30.892Z" }, + { url = "https://files.pythonhosted.org/packages/91/2f/55b82aa3d8d7dd8023a56e7c5c2a70e39a3c44b3353c6cf3faec9ad51566/coverage-7.15.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4d608dc36a364dce33acbf4fc3a50f9d2054c945f233bb0a2cdb4b90bfa17646", size = 253852, upload-time = "2026-08-02T18:48:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6d/839f4045124cd3518ecf2c58967e58a911202834e7c5a03cfdf2ab0b29f6/coverage-7.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2395869280554a1941da904423c12660c39f721315e1c02d076a7fe0971382f0", size = 255725, upload-time = "2026-08-02T18:48:34.848Z" }, + { url = "https://files.pythonhosted.org/packages/75/21/d25e3e2a9e327798078c877f469dfb6def860bf6e25036529046227d3e15/coverage-7.15.3-cp312-cp312-win32.whl", hash = "sha256:24f3b21840c3eb76cef3cc70b2bf6649010c64471a84a446538a39306e1ba04d", size = 224566, upload-time = "2026-08-02T18:48:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/b1/0f/df90cc1e8d095ce263968a93e04829821b2afb31ac2752c06a2e0a8e3c13/coverage-7.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:fa7b17902c3c1dd8a7adb52679b7f6340bba08443d710c8838e04db8cf62be2a", size = 225098, upload-time = "2026-08-02T18:48:38.941Z" }, + { url = "https://files.pythonhosted.org/packages/65/c7/ec49e43c58967a07163e2d1c6bbd58112b825b2772ab66784afd6a5400ba/coverage-7.15.3-cp312-cp312-win_arm64.whl", hash = "sha256:fcbe83fb7258eacd293bf5322d88807acb35ed12a5cfa99dd8215c083e3b0235", size = 224485, upload-time = "2026-08-02T18:48:40.682Z" }, + { url = "https://files.pythonhosted.org/packages/68/6e/62ae61e1fc434956bec38ed1d5b1c494f58cf579dbd998e77abffe7b3e6b/coverage-7.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1182eed05674c63d40951fae27c43e822749f04d25f75df64c2e4fa3168678de", size = 222522, upload-time = "2026-08-02T18:48:42.476Z" }, + { url = "https://files.pythonhosted.org/packages/13/ff/c74c673d81e0e77b6608c3d21331e3db42e30daeb3c8a0a8860d4c9e2e14/coverage-7.15.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c0c4b0d7c4cd56e470d0c9d8441f42e8a96cdfd95050fec027f1d4dd9f11006c", size = 222894, upload-time = "2026-08-02T18:48:44.274Z" }, + { url = "https://files.pythonhosted.org/packages/a1/91/ccb30f5ffafd7d69d0b18e5162f9b711a5654e807b7b0c13497f0826b33f/coverage-7.15.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5c9fce9f4998b0d50a753da765b9215a14decc7863822c89d72da7a89ca625b3", size = 253890, upload-time = "2026-08-02T18:48:46.097Z" }, + { url = "https://files.pythonhosted.org/packages/29/c6/e92a66cda49a2751b09826d51258f199b92aa0cb005bc5f34e9729a52a9c/coverage-7.15.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7a47e2a0a0ace9241e70ee00e44520f88b843094603dd54303f1bafecd929c30", size = 256484, upload-time = "2026-08-02T18:48:47.846Z" }, + { url = "https://files.pythonhosted.org/packages/96/7a/730929164b457cf25cf76c23898b90f9039a104a647890801b6586797b14/coverage-7.15.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:95bad94f83807ae60ed76f3ac012f69b2605ac9ea81bee959a5a483f7fa09c10", size = 257723, upload-time = "2026-08-02T18:48:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/9e/be/04cb5672cb19f5c389eda81ba22d89807699a949653d3625b0e0fda169da/coverage-7.15.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:228e172a76c428bb17d1ab78a2ff188990b0597e5dbd291f52a4edf7412de049", size = 259854, upload-time = "2026-08-02T18:48:51.413Z" }, + { url = "https://files.pythonhosted.org/packages/96/25/5e7fd6af39f6507071455944b8906dd1fe5b7b6bffb6a163ceb20afa0d13/coverage-7.15.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cea9fb33887c99349996266f1fd60abe5af3577a90633392001d27ef46b4b66e", size = 254085, upload-time = "2026-08-02T18:48:53.158Z" }, + { url = "https://files.pythonhosted.org/packages/23/c8/55e58a853f1e61163a6e755897bd14a059d78411e86560f39d9951c019b5/coverage-7.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:81760de3155d7f52c21860c4046628dc6bed182f72e3c028e2b4fd46f65aa040", size = 255850, upload-time = "2026-08-02T18:48:55.031Z" }, + { url = "https://files.pythonhosted.org/packages/be/74/8bcec66dbcf3d22bea2a0b2b77ee2fa6f766a647d0023d4eabbc4f2b2756/coverage-7.15.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b47ea0a1d3a3d089826c6cbfad8429d7d8872e28e86baa95ddef330f6875da21", size = 253818, upload-time = "2026-08-02T18:48:57.163Z" }, + { url = "https://files.pythonhosted.org/packages/ce/06/450b673fdfece0997b4e16a31d6bde6b18889c578f1013ddd34c962ac6f9/coverage-7.15.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5459ba486b2a5d58a6c05254779ecdf525e7f20174d0210ceda75ba40fdb8f2c", size = 257973, upload-time = "2026-08-02T18:48:59.098Z" }, + { url = "https://files.pythonhosted.org/packages/56/fd/3ec7409aec0ddc943132452b65672f065f043b844f1830e1fe173c98b3ab/coverage-7.15.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c59209f80a08dbfcdd5109a80dc623cd3b9d22895c85757d34f57a6e6e95570f", size = 253638, upload-time = "2026-08-02T18:49:01.199Z" }, + { url = "https://files.pythonhosted.org/packages/75/20/30a8dabb194123631c93f860fdd86401ad405d56cfb1841873afbfe4e92b/coverage-7.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f863856c1779d4a5bb6a94698a2f9073e09c6706501f76f3e7780e72df97d21c", size = 255407, upload-time = "2026-08-02T18:49:03.143Z" }, + { url = "https://files.pythonhosted.org/packages/13/4d/e14365b1953b43653341412f9088b0d752614c626a73a705ff9af400f3a3/coverage-7.15.3-cp313-cp313-win32.whl", hash = "sha256:00cbdc5e322927dc30c5e42b863819b1bb867cc66f26ab5372c585850876ab93", size = 224575, upload-time = "2026-08-02T18:49:05.011Z" }, + { url = "https://files.pythonhosted.org/packages/1c/64/88f762ea80de2070207246faef514513be874486b2773528f2cc2b4b515c/coverage-7.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:835528518a1d823cf336740324b2f335f7c01e609e74abcb5d5163b3e66661e3", size = 225116, upload-time = "2026-08-02T18:49:06.894Z" }, + { url = "https://files.pythonhosted.org/packages/ab/66/03c34c53a319f522554cd29d4f2e16c5eab61aa4cdcf55753129fd7d926c/coverage-7.15.3-cp313-cp313-win_arm64.whl", hash = "sha256:0d2e1f2cbbf36b842f3e2aff8d118c60d677adb498bc6c7fa9c6838738f82767", size = 224509, upload-time = "2026-08-02T18:49:09.129Z" }, + { url = "https://files.pythonhosted.org/packages/35/6f/8c2dc014357618b3226c90f731b8282766c3685786f422558991dc49fbf2/coverage-7.15.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1e3bb08ad574bd9fb6a991f645728f70d333c1c1958dd5fcde65e24cb862813d", size = 222571, upload-time = "2026-08-02T18:49:11.242Z" }, + { url = "https://files.pythonhosted.org/packages/07/50/d867c7ceae9d56b7e74ee61ea834f1aa4f9a1e1c7f0ce39393ba573b1c12/coverage-7.15.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9e5860eaff02a0b7f1b73304bdf846596ee62ab3a78d25c68044ebf684cb1fef", size = 222902, upload-time = "2026-08-02T18:49:13.448Z" }, + { url = "https://files.pythonhosted.org/packages/62/77/4f6dfc490c5f2bcacb2d296d9aa4d1e128c43b48e94ad313fec7f49f09ad/coverage-7.15.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:60874e5bd67f0b1bdbe42ab42c7bafa66a6fb8de88721af6df3f7a02713960cd", size = 253947, upload-time = "2026-08-02T18:49:15.304Z" }, + { url = "https://files.pythonhosted.org/packages/16/8a/6777f192af264165103e2a3d3768dbadb9894a0a2359a16877141d9ae8f5/coverage-7.15.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9147be876e9d83765e0b82176674dc248a6b9283e25e01e7462611b97e9b731", size = 256452, upload-time = "2026-08-02T18:49:17.801Z" }, + { url = "https://files.pythonhosted.org/packages/7d/7b/3d7ac46a0234bc684f41ee42be95e29b2b6525695adb04083609d5ac2149/coverage-7.15.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61a01f8c3804760fcc5a3d31c4f3cab792d660d44e17bf7adeaf0ea51e07821e", size = 257798, upload-time = "2026-08-02T18:49:19.878Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1e/c6ee59c29afcb5fdb35f936381340d1a06429a07c48f20e809646647acbe/coverage-7.15.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:95bf3e7f26f792e25eb185f85a5a659d48479265176dcfe22b6f334fd0081b5c", size = 260112, upload-time = "2026-08-02T18:49:21.858Z" }, + { url = "https://files.pythonhosted.org/packages/c1/e1/e8ea39a46e89e3a143312ee5f80336e992e3ae8fe44bf9c76b83fefeed42/coverage-7.15.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:44c41eff9e413fed8740eca75d5438ebeb9d3e45e7cd37c67329213e7a72c764", size = 253944, upload-time = "2026-08-02T18:49:23.926Z" }, + { url = "https://files.pythonhosted.org/packages/95/67/31ab5f6a37fd887d1386f81f0da9306851ad2264e9baaa9c7f606e0b3e17/coverage-7.15.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54146bafb61f3ba9895b43af0dd17eba01561d586d44ce84ea221b0cbbee5a9e", size = 255805, upload-time = "2026-08-02T18:49:25.973Z" }, + { url = "https://files.pythonhosted.org/packages/fb/6a/ee505a80c8fd89620fb337c0596daecff87f33171fbb4ee3015fc3d7331f/coverage-7.15.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:af000dd1bb859ff8066fda4c79512ff938c798116540307226b373099c7b151f", size = 253769, upload-time = "2026-08-02T18:49:27.883Z" }, + { url = "https://files.pythonhosted.org/packages/b0/41/6ab0f81c9e89660230d8f3f581d4732e5ddb75a885b0a5dfc73d315dc94f/coverage-7.15.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a1b82490577f3889950b5a04f18712aef0207243e0749d60fe28c3c73ebfd5fd", size = 258045, upload-time = "2026-08-02T18:49:30.201Z" }, + { url = "https://files.pythonhosted.org/packages/bc/62/c995e91cae28cf31d6defab3bfb553dda5ac83ac7381b0f2b121264c307a/coverage-7.15.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:c4fc90a60154c3e4b8a2dc206d6dbe852f1c235c249e0dc0cef909d032c9591a", size = 253587, upload-time = "2026-08-02T18:49:32.349Z" }, + { url = "https://files.pythonhosted.org/packages/84/df/f2049980f82d6890321f2065f9e66216eabbf4b2001815db958bc543f40a/coverage-7.15.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f25bb884814a892948b4c20394db3f2364dd452d9492736479e7a493e63b0eb6", size = 255243, upload-time = "2026-08-02T18:49:34.324Z" }, + { url = "https://files.pythonhosted.org/packages/1d/82/2c841b67a978c0eb9c3707630b68f93f9e7585d78bb906bc8823ec6b07a5/coverage-7.15.3-cp314-cp314-win32.whl", hash = "sha256:722dbf8e7828fbcfe0dc8586167dc0a5ce85ad6ea171dbb21ed3f8d6581d3cb8", size = 224759, upload-time = "2026-08-02T18:49:36.326Z" }, + { url = "https://files.pythonhosted.org/packages/b3/78/5c93ec43784fd3e404ca23cd0584ae24bc1732de4a3fc194b68c3be88db0/coverage-7.15.3-cp314-cp314-win_amd64.whl", hash = "sha256:64d0845f9c3ed47302bed265c15ab4dbb64aa4ec1490839b8e328f4e7fa914d2", size = 225246, upload-time = "2026-08-02T18:49:38.366Z" }, + { url = "https://files.pythonhosted.org/packages/9d/77/813a054371f3b018cc63c6bdb46a3c35d5e95d4e3ed4f1449d4196106db5/coverage-7.15.3-cp314-cp314-win_arm64.whl", hash = "sha256:69bc14684f8fbbee9f9dbaa4fe79719b0da9725fc37956785c06ec365acf6926", size = 224673, upload-time = "2026-08-02T18:49:40.552Z" }, + { url = "https://files.pythonhosted.org/packages/8f/63/8c9f36cc71178d26db930baa03a4494abcc516d8d41bf820d0d85ef1d80b/coverage-7.15.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f92df943c24b96cb215ca26b4f6a2283e63c5db80f1635aceea7fff11311917b", size = 223298, upload-time = "2026-08-02T18:49:42.634Z" }, + { url = "https://files.pythonhosted.org/packages/54/66/211f24d058ce9f56ebf1420d55b7574fdae924f6da3836f83c8bd4793e38/coverage-7.15.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:66591c46bdd2971d3ae2bc503a5f0459c2edcaf6b7e045b292000cc95bc6cb95", size = 223568, upload-time = "2026-08-02T18:49:44.706Z" }, + { url = "https://files.pythonhosted.org/packages/dd/bb/9c2ad5574a0d6420a96c6cade4f8a683931b9e79fe609f8924d7b6964616/coverage-7.15.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa64458b81b18bfc67cdf1f6dc02b23e3edc672f2f8e11771fad75865415a43", size = 264932, upload-time = "2026-08-02T18:49:47.153Z" }, + { url = "https://files.pythonhosted.org/packages/ba/91/938c39e77bdd5a0a440412f975609ce3702dabbda6ac715719d93ca45a7b/coverage-7.15.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:447f5421ccf5475956cf516d4ca1d575f487947b6f4e11f9d80c6aefe24b3dc8", size = 267052, upload-time = "2026-08-02T18:49:49.324Z" }, + { url = "https://files.pythonhosted.org/packages/b0/a3/7b431a98af35d9cc6394e54cde9435b33b8591672fbece6a4931267d7a8e/coverage-7.15.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a0c77ef8cd483a4987a5d12d1d9d5f7ee598dfdc6c0844417d847e5768dc779", size = 269473, upload-time = "2026-08-02T18:49:51.599Z" }, + { url = "https://files.pythonhosted.org/packages/32/58/dbc9951dce46be47a732823a1c571f62bcabdd54a68d8c281489a1a55cfb/coverage-7.15.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0b273f4ff657446a06c2d85bf80e134fa869a92852ba5f87854a70e1fb44da77", size = 270591, upload-time = "2026-08-02T18:49:53.865Z" }, + { url = "https://files.pythonhosted.org/packages/71/bd/1d610772c7c0889bfe477a59c46ee66ea53e271f3f06951e9d55b317f7c6/coverage-7.15.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daea8c4fafa22488600405be2c2be525a9406fba3fc0a83acc726db3e14e2005", size = 264007, upload-time = "2026-08-02T18:49:55.875Z" }, + { url = "https://files.pythonhosted.org/packages/69/97/852eb3dcdba156b1a9078503f098499916bf889f964b61ad4a08223ac169/coverage-7.15.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:93ff57c530f3fa7aa69f92fb9b8892b8aa82712aa970842f4abf28657f42fb57", size = 266926, upload-time = "2026-08-02T18:49:57.944Z" }, + { url = "https://files.pythonhosted.org/packages/52/f8/b72cd238757fba2b587fc7dee047efe6e10b0c18343509faaaf502dd4680/coverage-7.15.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4df21bef8b800eebda9018f53d49c9ace3aeb0090c850139b27923aafcb83e91", size = 264529, upload-time = "2026-08-02T18:50:00.035Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0a/6c52ec4b7fb007cb6433d1fcfda4080cb15d75ad37ef9c31025f3427293e/coverage-7.15.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:db567b02685f26034adcbd85055f80d12cdf02111b8ed00886093d98b2874ce2", size = 268263, upload-time = "2026-08-02T18:50:02.161Z" }, + { url = "https://files.pythonhosted.org/packages/c0/4e/f1f9aa3efd109a04353563a43fb5155340c1fdcdeaa6296ebed3b6f510ea/coverage-7.15.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5318dd51b8600b947e058cf5a4fe54d183d9d13c49b97b64ca7be05a34df9bef", size = 263377, upload-time = "2026-08-02T18:50:04.243Z" }, + { url = "https://files.pythonhosted.org/packages/dd/fb/6b268a0b2728ef1c379ad656b899274477a5f6bed1bf6765b4b387fb0601/coverage-7.15.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c995bfa383c54704839b6c4c2627a1c00895597ada0e5e8190c81d8bd620555c", size = 265688, upload-time = "2026-08-02T18:50:06.428Z" }, + { url = "https://files.pythonhosted.org/packages/29/54/1a3ea96e5d5e7cd41dc432597bfc60692910e635d05e1cc25a8ccc243581/coverage-7.15.3-cp314-cp314t-win32.whl", hash = "sha256:6433fafb8da0e1d02eb53411e0ecdadb6b88f0224fdc23317e703c0e88937d42", size = 225066, upload-time = "2026-08-02T18:50:08.533Z" }, + { url = "https://files.pythonhosted.org/packages/31/9d/a7b0d9afd18ed5274dd00651a78e7810a931c70d94b79996f150bec1a30f/coverage-7.15.3-cp314-cp314t-win_amd64.whl", hash = "sha256:fe578952b1b29fe8c777f43f241d49efac4b56724a3434f5d22ebe3c208df429", size = 225897, upload-time = "2026-08-02T18:50:10.572Z" }, + { url = "https://files.pythonhosted.org/packages/ca/11/34c5ae40b945e69aa72b87dc268135b7049905f3824af573b7073acbb946/coverage-7.15.3-cp314-cp314t-win_arm64.whl", hash = "sha256:d2e1acb7aee29dfa8f3e48c23f36670898baca1209d9bdd3985a50c7f982165e", size = 225212, upload-time = "2026-08-02T18:50:12.63Z" }, + { url = "https://files.pythonhosted.org/packages/37/e7/7069b3d6c018917f49ba2e1c5fb910e498c7fefa3a1b78cb1b79e61ff45d/coverage-7.15.3-py3-none-any.whl", hash = "sha256:da78fa6fc7dafe4212839173133ee85afcf42c5cd5f3e47fa7c1c210453b445e", size = 214297, upload-time = "2026-08-02T18:50:14.709Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version >= '3.10' and python_full_version <= '3.11'" }, +] + +[[package]] +name = "cramjam" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/14/12/34bf6e840a79130dfd0da7badfb6f7810b8fcfd60e75b0539372667b41b6/cramjam-2.11.0.tar.gz", hash = "sha256:5c82500ed91605c2d9781380b378397012e25127e89d64f460fea6aeac4389b4", size = 99100, upload-time = "2025-07-27T21:25:07.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/d3/20d0402e4e983b66603117ad3dd3b864a05d7997a830206d3ff9cacef9a2/cramjam-2.11.0-cp310-cp310-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:d0859c65775e8ebf2cbc084bfd51bd0ffda10266da6f9306451123b89f8e5a63", size = 3558999, upload-time = "2025-07-27T21:21:34.105Z" }, + { url = "https://files.pythonhosted.org/packages/f5/a8/a6e2744288938ccd320a5c6f6f3653faa790f933f5edd088c6e5782a2354/cramjam-2.11.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:1d77b9b0aca02a3f6eeeff27fcd315ca5972616c0919ee38e522cce257bcd349", size = 1861558, upload-time = "2025-07-27T21:21:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/96/29/7961e09a849eea7d8302e7baa6f829dd3ef3faf199cb25ed29b318ae799b/cramjam-2.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:66425bc25b5481359b12a6719b6e7c90ffe76d85d0691f1da7df304bfb8ce45c", size = 1699431, upload-time = "2025-07-27T21:21:38.396Z" }, + { url = "https://files.pythonhosted.org/packages/7a/60/6665e52f01a8919bf37c43dcf0e03b6dd3866f5c4e95440b357d508ee14e/cramjam-2.11.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:bd748d3407ec63e049b3aea1595e218814fccab329b7fb10bb51120a30e9fb7e", size = 2025262, upload-time = "2025-07-27T21:21:40.417Z" }, + { url = "https://files.pythonhosted.org/packages/d7/80/79bd84dbeb109e2c6efb74e661b7bd4c3ba393208ebcf69e2ae9454ae80c/cramjam-2.11.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6d9a23a35b3a105c42a8de60fc2e80281ae6e758f05a3baea0b68eb1ddcb679", size = 1766177, upload-time = "2025-07-27T21:21:42.224Z" }, + { url = "https://files.pythonhosted.org/packages/28/ef/b43280767ebcde022ba31f1e9902137655a956ae30e920d75630fa67e36e/cramjam-2.11.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:40a75b95e05e38a2a055b2446f09994ce1139151721659315151d4ad6289bbff", size = 1854031, upload-time = "2025-07-27T21:21:43.651Z" }, + { url = "https://files.pythonhosted.org/packages/60/1c/79d522757c494dfd9e9b208b0604cc7e97b481483cc477144f5705a06ab7/cramjam-2.11.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e5d042c376d2025300da37d65192d06a457918b63b31140f697f85fd8e310b29", size = 2035812, upload-time = "2025-07-27T21:21:45.473Z" }, + { url = "https://files.pythonhosted.org/packages/c8/70/3bf0670380069b3abd4c6b53f61d3148f4e08935569c08efbeaf7550e87d/cramjam-2.11.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cb148b35ab20c75b19a06c27f05732e2a321adbd86fadc93f9466dbd7b1154a7", size = 2067661, upload-time = "2025-07-27T21:21:47.901Z" }, + { url = "https://files.pythonhosted.org/packages/db/7e/4f6ca98a4b474348e965a529b359184785d1119ab7c4c9ec1280b8bea50a/cramjam-2.11.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ee47c220f0f5179ddc923ab91fc9e282c27b29fabc60c433dfe06f08084f798", size = 1981523, upload-time = "2025-07-27T21:21:49.704Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6c/b241511c7ffd5f1da29641429bb0e19b5fbcffafde5ba1bbcbf9394ea456/cramjam-2.11.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0cf1b5a81b21ea175c976c3ab09e00494258f4b49b7995efc86060cced3f0b2e", size = 2034251, upload-time = "2025-07-27T21:21:51.252Z" }, + { url = "https://files.pythonhosted.org/packages/14/5c/4ef926c8c3c1bf6da96f9c53450ff334cdb6d0fc1efced0aea97e2090803/cramjam-2.11.0-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:360c00338ecf48921492455007f904be607fc7818de3d681acbcc542aae2fb36", size = 2155322, upload-time = "2025-07-27T21:21:53.348Z" }, + { url = "https://files.pythonhosted.org/packages/be/fb/eb2aef7fb2730e56c5a2c9000817ee8fb4a95c92f19cc6e441afed42ec29/cramjam-2.11.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:f31fcc0d30dc3f3e94ea6b4d8e1a855071757c6abf6a7b1e284050ab7d4c299c", size = 2169094, upload-time = "2025-07-27T21:21:55.187Z" }, + { url = "https://files.pythonhosted.org/packages/3b/80/925a5c668dcee1c6f61775067185c5dc9a63c766d5393e5c60d2af4217a7/cramjam-2.11.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:033be66fdceb3d63b2c99b257a98380c4ec22c9e4dca54a2bfec3718cd24e184", size = 2159089, upload-time = "2025-07-27T21:21:57.118Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ac/b2819640eef0592a6de7ca832c0d23c69bd1620f765ce88b60dbc8da9ba2/cramjam-2.11.0-cp310-cp310-win32.whl", hash = "sha256:1c6cea67f6000b81f6bd27d14c8a6f62d00336ca7252fd03ee16f6b70eb5c0d2", size = 1605046, upload-time = "2025-07-27T21:21:58.617Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f4/06af04727b9556721049e2127656d727306d275c518e3d97f9ed4cffd0d8/cramjam-2.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:98aa4a351b047b0f7f9e971585982065028adc2c162c5c23c5d5734c5ccc1077", size = 1710647, upload-time = "2025-07-27T21:22:00.279Z" }, + { url = "https://files.pythonhosted.org/packages/d0/89/8001f6a9b6b6e9fa69bec5319789083475d6f26d52aaea209d3ebf939284/cramjam-2.11.0-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:04cfa39118570e70e920a9b75c733299784b6d269733dbc791d9aaed6edd2615", size = 3559272, upload-time = "2025-07-27T21:22:01.988Z" }, + { url = "https://files.pythonhosted.org/packages/0b/f3/001d00070ca92e5fbe6aacc768e455568b0cde46b0eb944561a4ea132300/cramjam-2.11.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:66a18f68506290349a256375d7aa2f645b9f7993c10fc4cc211db214e4e61d2b", size = 1861743, upload-time = "2025-07-27T21:22:03.754Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/041a3af01bf3f6158f120070f798546d4383b962b63c35cd91dcbf193e17/cramjam-2.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:50e7d65533857736cd56f6509cf2c4866f28ad84dd15b5bdbf2f8a81e77fa28a", size = 1699631, upload-time = "2025-07-27T21:22:05.192Z" }, + { url = "https://files.pythonhosted.org/packages/17/eb/5358b238808abebd0c949c42635c3751204ca7cf82b29b984abe9f5e33c8/cramjam-2.11.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1f71989668458fc327ac15396db28d92df22f8024bb12963929798b2729d2df5", size = 2025603, upload-time = "2025-07-27T21:22:06.726Z" }, + { url = "https://files.pythonhosted.org/packages/0e/79/19dba7c03a27408d8d11b5a7a4a7908459cfd4e6f375b73264dc66517bf6/cramjam-2.11.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ee77ac543f1e2b22af1e8be3ae589f729491b6090582340aacd77d1d757d9569", size = 1766283, upload-time = "2025-07-27T21:22:08.568Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ad/40e4b3408501d886d082db465c33971655fe82573c535428e52ab905f4d0/cramjam-2.11.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ad52784120e7e4d8a0b5b0517d185b8bf7f74f5e17272857ddc8951a628d9be1", size = 1854407, upload-time = "2025-07-27T21:22:10.518Z" }, + { url = "https://files.pythonhosted.org/packages/36/6e/c1b60ceb6d7ea6ff8b0bf197520aefe23f878bf2bfb0de65f2b0c2f82cd1/cramjam-2.11.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b86f8e6d9c1b3f9a75b2af870c93ceee0f1b827cd2507387540e053b35d7459", size = 2035793, upload-time = "2025-07-27T21:22:12.504Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ad/32a8d5f4b1e3717787945ec6d71bd1c6e6bccba4b7e903fc0d9d4e4b08c3/cramjam-2.11.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:320d61938950d95da2371b46c406ec433e7955fae9f396c8e1bf148ffc187d11", size = 2067499, upload-time = "2025-07-27T21:22:14.067Z" }, + { url = "https://files.pythonhosted.org/packages/ff/cd/3b5a662736ea62ff7fa4c4a10a85e050bfdaad375cc53dc80427e8afe41c/cramjam-2.11.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41eafc8c1653a35a5c7e75ad48138f9f60085cc05cd99d592e5298552d944e9f", size = 1981853, upload-time = "2025-07-27T21:22:15.908Z" }, + { url = "https://files.pythonhosted.org/packages/26/8e/1dbcfaaa7a702ee82ee683ec3a81656934dd7e04a7bc4ee854033686f98a/cramjam-2.11.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:03a7316c6bf763dfa34279335b27702321da44c455a64de58112968c0818ec4a", size = 2034514, upload-time = "2025-07-27T21:22:17.352Z" }, + { url = "https://files.pythonhosted.org/packages/50/62/f11709bfdce74af79a88b410dcb76dedc97612166e759136931bf63cfd7b/cramjam-2.11.0-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:244c2ed8bd7ccbb294a2abe7ca6498db7e89d7eb5e744691dc511a7dc82e65ca", size = 2155343, upload-time = "2025-07-27T21:22:18.854Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6d/3b98b61841a5376d9a9b8468ae58753a8e6cf22be9534a0fa5af4d8621cc/cramjam-2.11.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:405f8790bad36ce0b4bbdb964ad51507bfc7942c78447f25cb828b870a1d86a0", size = 2169367, upload-time = "2025-07-27T21:22:20.389Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/bd5db5c49dbebc8b002f1c4983101b28d2e7fc9419753db1c31ec22b03ef/cramjam-2.11.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6b1b751a5411032b08fb3ac556160229ca01c6bbe4757bb3a9a40b951ebaac23", size = 2159334, upload-time = "2025-07-27T21:22:22.254Z" }, + { url = "https://files.pythonhosted.org/packages/34/32/203c57acdb6eea727e7078b2219984e64ed4ad043c996ed56321301ba167/cramjam-2.11.0-cp311-cp311-win32.whl", hash = "sha256:5251585608778b9ac8effed544933df7ad85b4ba21ee9738b551f17798b215ac", size = 1605313, upload-time = "2025-07-27T21:22:24.126Z" }, + { url = "https://files.pythonhosted.org/packages/a9/bd/102d6deb87a8524ac11cddcd31a7612b8f20bf9b473c3c645045e3b957c7/cramjam-2.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:dca88bc8b68ce6d35dafd8c4d5d59a238a56c43fa02b74c2ce5f9dfb0d1ccb46", size = 1710991, upload-time = "2025-07-27T21:22:25.661Z" }, + { url = "https://files.pythonhosted.org/packages/0b/0d/7c84c913a5fae85b773a9dcf8874390f9d68ba0fcc6630efa7ff1541b950/cramjam-2.11.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:dba5c14b8b4f73ea1e65720f5a3fe4280c1d27761238378be8274135c60bbc6e", size = 3553368, upload-time = "2025-07-27T21:22:27.162Z" }, + { url = "https://files.pythonhosted.org/packages/2b/cc/4f6d185d8a744776f53035e72831ff8eefc2354f46ab836f4bd3c4f6c138/cramjam-2.11.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:11eb40722b3fcf3e6890fba46c711bf60f8dc26360a24876c85e52d76c33b25b", size = 1860014, upload-time = "2025-07-27T21:22:28.738Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a8/626c76263085c6d5ded0e71823b411e9522bfc93ba6cc59855a5869296e7/cramjam-2.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:aeb26e2898994b6e8319f19a4d37c481512acdcc6d30e1b5ecc9d8ec57e835cb", size = 1693512, upload-time = "2025-07-27T21:22:30.999Z" }, + { url = "https://files.pythonhosted.org/packages/e9/52/0851a16a62447532e30ba95a80e638926fdea869a34b4b5b9d0a020083ba/cramjam-2.11.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4f8d82081ed7d8fe52c982bd1f06e4c7631a73fe1fb6d4b3b3f2404f87dc40fe", size = 2025285, upload-time = "2025-07-27T21:22:32.954Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/122e444f59dbc216451d8e3d8282c9665dc79eaf822f5f1470066be1b695/cramjam-2.11.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:092a3ec26e0a679305018380e4f652eae1b6dfe3fc3b154ee76aa6b92221a17c", size = 1761327, upload-time = "2025-07-27T21:22:34.484Z" }, + { url = "https://files.pythonhosted.org/packages/a3/bc/3a0189aef1af2b29632c039c19a7a1b752bc21a4053582a5464183a0ad3d/cramjam-2.11.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:529d6d667c65fd105d10bd83d1cd3f9869f8fd6c66efac9415c1812281196a92", size = 1854075, upload-time = "2025-07-27T21:22:36.157Z" }, + { url = "https://files.pythonhosted.org/packages/2e/80/8a6343b13778ce52d94bb8d5365a30c3aa951276b1857201fe79d7e2ad25/cramjam-2.11.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:555eb9c90c450e0f76e27d9ff064e64a8b8c6478ab1a5594c91b7bc5c82fd9f0", size = 2032710, upload-time = "2025-07-27T21:22:38.17Z" }, + { url = "https://files.pythonhosted.org/packages/df/6b/cd1778a207c29eda10791e3dfa018b588001928086e179fc71254793c625/cramjam-2.11.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5edf4c9e32493035b514cf2ba0c969d81ccb31de63bd05490cc8bfe3b431674e", size = 2068353, upload-time = "2025-07-27T21:22:39.615Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f0/5c2a5cd5711032f3b191ca50cb786c17689b4a9255f9f768866e6c9f04d9/cramjam-2.11.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fa2fe41f48c4d58d923803383b0737f048918b5a0d10390de9628bb6272b107", size = 1978104, upload-time = "2025-07-27T21:22:41.106Z" }, + { url = "https://files.pythonhosted.org/packages/f9/8b/b363a5fb2c3347504fe9a64f8d0f1e276844f0e532aa7162c061cd1ffee4/cramjam-2.11.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9ca14cf1cabdb0b77d606db1bb9e9ca593b1dbd421fcaf251ec9a5431ec449f3", size = 2030779, upload-time = "2025-07-27T21:22:42.969Z" }, + { url = "https://files.pythonhosted.org/packages/78/7b/d83dad46adb6c988a74361f81ad9c5c22642be53ad88616a19baedd06243/cramjam-2.11.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:309e95bf898829476bccf4fd2c358ec00e7ff73a12f95a3cdeeba4bb1d3683d5", size = 2155297, upload-time = "2025-07-27T21:22:44.6Z" }, + { url = "https://files.pythonhosted.org/packages/1a/be/60d9be4cb33d8740a4aa94c7513f2ef3c4eba4fd13536f086facbafade71/cramjam-2.11.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:86dca35d2f15ef22922411496c220f3c9e315d5512f316fe417461971cc1648d", size = 2169255, upload-time = "2025-07-27T21:22:46.534Z" }, + { url = "https://files.pythonhosted.org/packages/11/b0/4a595f01a243aec8ad272b160b161c44351190c35d98d7787919d962e9e5/cramjam-2.11.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:193c6488bd2f514cbc0bef5c18fad61a5f9c8d059dd56edf773b3b37f0e85496", size = 2155651, upload-time = "2025-07-27T21:22:48.46Z" }, + { url = "https://files.pythonhosted.org/packages/38/47/7776659aaa677046b77f527106e53ddd47373416d8fcdb1e1a881ec5dc06/cramjam-2.11.0-cp312-cp312-win32.whl", hash = "sha256:514e2c008a8b4fa823122ca3ecab896eac41d9aa0f5fc881bd6264486c204e32", size = 1603568, upload-time = "2025-07-27T21:22:50.084Z" }, + { url = "https://files.pythonhosted.org/packages/75/b1/d53002729cfd94c5844ddfaf1233c86d29f2dbfc1b764a6562c41c044199/cramjam-2.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:53fed080476d5f6ad7505883ec5d1ec28ba36c2273db3b3e92d7224fe5e463db", size = 1709287, upload-time = "2025-07-27T21:22:51.534Z" }, + { url = "https://files.pythonhosted.org/packages/0a/8b/406c5dc0f8e82385519d8c299c40fd6a56d97eca3fcd6f5da8dad48de75b/cramjam-2.11.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:2c289729cc1c04e88bafa48b51082fb462b0a57dbc96494eab2be9b14dca62af", size = 3553330, upload-time = "2025-07-27T21:22:53.124Z" }, + { url = "https://files.pythonhosted.org/packages/00/ad/4186884083d6e4125b285903e17841827ab0d6d0cffc86216d27ed91e91d/cramjam-2.11.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:045201ee17147e36cf43d8ae2fa4b4836944ac672df5874579b81cf6d40f1a1f", size = 1859756, upload-time = "2025-07-27T21:22:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/54/01/91b485cf76a7efef638151e8a7d35784dae2c4ff221b1aec2c083e4b106d/cramjam-2.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:619cd195d74c9e1d2a3ad78d63451d35379c84bd851aec552811e30842e1c67a", size = 1693609, upload-time = "2025-07-27T21:22:56.331Z" }, + { url = "https://files.pythonhosted.org/packages/cd/84/d0c80d279b2976870fc7d10f15dcb90a3c10c06566c6964b37c152694974/cramjam-2.11.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6eb3ae5ab72edb2ed68bdc0f5710f0a6cad7fd778a610ec2c31ee15e32d3921e", size = 2024912, upload-time = "2025-07-27T21:22:57.915Z" }, + { url = "https://files.pythonhosted.org/packages/d6/70/88f2a5cb904281ed5d3c111b8f7d5366639817a5470f059bcd26833fc870/cramjam-2.11.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df7da3f4b19e3078f9635f132d31b0a8196accb2576e3213ddd7a77f93317c20", size = 1760715, upload-time = "2025-07-27T21:22:59.528Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/cf5b02081132537d28964fb385fcef9ed9f8a017dd7d8c59d317e53ba50d/cramjam-2.11.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:57286b289cd557ac76c24479d8ecfb6c3d5b854cce54ccc7671f9a2f5e2a2708", size = 1853782, upload-time = "2025-07-27T21:23:01.07Z" }, + { url = "https://files.pythonhosted.org/packages/57/27/63525087ed40a53d1867021b9c4858b80cc86274ffe7225deed067d88d92/cramjam-2.11.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:28952fbbf8b32c0cb7fa4be9bcccfca734bf0d0989f4b509dc7f2f70ba79ae06", size = 2032354, upload-time = "2025-07-27T21:23:03.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/ef/dbba082c6ebfb6410da4dd39a64e654d7194fcfd4567f85991a83fa4ec32/cramjam-2.11.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:78ed2e4099812a438b545dfbca1928ec825e743cd253bc820372d6ef8c3adff4", size = 2068007, upload-time = "2025-07-27T21:23:04.526Z" }, + { url = "https://files.pythonhosted.org/packages/35/ce/d902b9358a46a086938feae83b2251720e030f06e46006f4c1fc0ac9da20/cramjam-2.11.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d9aecd5c3845d415bd6c9957c93de8d93097e269137c2ecb0e5a5256374bdc8", size = 1977485, upload-time = "2025-07-27T21:23:06.058Z" }, + { url = "https://files.pythonhosted.org/packages/e8/03/982f54553244b0afcbdb2ad2065d460f0ab05a72a96896a969a1ca136a1e/cramjam-2.11.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:362fcf4d6f5e1242a4540812455f5a594949190f6fbc04f2ffbfd7ae0266d788", size = 2030447, upload-time = "2025-07-27T21:23:07.679Z" }, + { url = "https://files.pythonhosted.org/packages/74/5f/748e54cdb665ec098ec519e23caacc65fc5ae58718183b071e33fc1c45b4/cramjam-2.11.0-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:13240b3dea41b1174456cb9426843b085dc1a2bdcecd9ee2d8f65ac5703374b0", size = 2154949, upload-time = "2025-07-27T21:23:09.366Z" }, + { url = "https://files.pythonhosted.org/packages/69/81/c4e6cb06ed69db0dc81f9a8b1dc74995ebd4351e7a1877143f7031ff2700/cramjam-2.11.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:c54eed83726269594b9086d827decc7d2015696e31b99bf9b69b12d9063584fe", size = 2168925, upload-time = "2025-07-27T21:23:10.976Z" }, + { url = "https://files.pythonhosted.org/packages/13/5b/966365523ce8290a08e163e3b489626c5adacdff2b3da9da1b0823dfb14e/cramjam-2.11.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f8195006fdd0fc0a85b19df3d64a3ef8a240e483ae1dfc7ac6a4316019eb5df2", size = 2154950, upload-time = "2025-07-27T21:23:12.514Z" }, + { url = "https://files.pythonhosted.org/packages/3a/7d/7f8eb5c534b72b32c6eb79d74585bfee44a9a5647a14040bb65c31c2572d/cramjam-2.11.0-cp313-cp313-win32.whl", hash = "sha256:ccf30e3fe6d770a803dcdf3bb863fa44ba5dc2664d4610ba2746a3c73599f2e4", size = 1603199, upload-time = "2025-07-27T21:23:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/37/05/47b5e0bf7c41a3b1cdd3b7c2147f880c93226a6bef1f5d85183040cbdece/cramjam-2.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:ee36348a204f0a68b03400f4736224e9f61d1c6a1582d7f875c1ca56f0254268", size = 1708924, upload-time = "2025-07-27T21:23:16.332Z" }, + { url = "https://files.pythonhosted.org/packages/de/07/a1051cdbbe6d723df16d756b97f09da7c1adb69e29695c58f0392bc12515/cramjam-2.11.0-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7ba5e38c9fbd06f086f4a5a64a1a5b7b417cd3f8fc07a20e5c03651f72f36100", size = 3554141, upload-time = "2025-07-27T21:23:17.938Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/58487d2e16ef3d04f51a7c7f0e69823e806744b4c21101e89da4873074bc/cramjam-2.11.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8adeee57b41fe08e4520698a4b0bd3cc76dbd81f99424b806d70a5256a391d3", size = 1860353, upload-time = "2025-07-27T21:23:19.593Z" }, + { url = "https://files.pythonhosted.org/packages/67/b4/67f6254d166ffbcc9d5fa1b56876eaa920c32ebc8e9d3d525b27296b693b/cramjam-2.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b96a74fa03a636c8a7d76f700d50e9a8bc17a516d6a72d28711225d641e30968", size = 1693832, upload-time = "2025-07-27T21:23:21.185Z" }, + { url = "https://files.pythonhosted.org/packages/55/a3/4e0b31c0d454ae70c04684ed7c13d3c67b4c31790c278c1e788cb804fa4a/cramjam-2.11.0-cp314-cp314-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c3811a56fa32e00b377ef79121c0193311fd7501f0fb378f254c7f083cc1fbe0", size = 2027080, upload-time = "2025-07-27T21:23:23.303Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c7/5e8eed361d1d3b8be14f38a54852c5370cc0ceb2c2d543b8ba590c34f080/cramjam-2.11.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5d927e87461f8a0d448e4ab5eb2bca9f31ca5d8ea86d70c6f470bb5bc666d7e", size = 1761543, upload-time = "2025-07-27T21:23:24.991Z" }, + { url = "https://files.pythonhosted.org/packages/09/0c/06b7f8b0ce9fde89470505116a01fc0b6cb92d406c4fb1e46f168b5d3fa5/cramjam-2.11.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f1f5c450121430fd89cb5767e0a9728ecc65997768fd4027d069cb0368af62f9", size = 1854636, upload-time = "2025-07-27T21:23:26.987Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c6/6ebc02c9d5acdf4e5f2b1ec6e1252bd5feee25762246798ae823b3347457/cramjam-2.11.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:724aa7490be50235d97f07e2ca10067927c5d7f336b786ddbc868470e822aa25", size = 2032715, upload-time = "2025-07-27T21:23:28.603Z" }, + { url = "https://files.pythonhosted.org/packages/a2/77/a122971c23f5ca4b53e4322c647ac7554626c95978f92d19419315dddd05/cramjam-2.11.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54c4637122e7cfd7aac5c1d3d4c02364f446d6923ea34cf9d0e8816d6e7a4936", size = 2069039, upload-time = "2025-07-27T21:23:30.319Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f6121b90b86b9093c066889274d26a1de3f29969d45c2ed1ecbe2033cb78/cramjam-2.11.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17eb39b1696179fb471eea2de958fa21f40a2cd8bf6b40d428312d5541e19dc4", size = 1979566, upload-time = "2025-07-27T21:23:32.002Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/f95bc57fd7f4166ce6da816cfa917fb7df4bb80e669eb459d85586498414/cramjam-2.11.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:36aa5a798aa34e11813a80425a30d8e052d8de4a28f27bfc0368cfc454d1b403", size = 2030905, upload-time = "2025-07-27T21:23:33.696Z" }, + { url = "https://files.pythonhosted.org/packages/fc/52/e429de4e8bc86ee65e090dae0f87f45abd271742c63fb2d03c522ffde28a/cramjam-2.11.0-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:449fca52774dc0199545fbf11f5128933e5a6833946707885cf7be8018017839", size = 2155592, upload-time = "2025-07-27T21:23:35.375Z" }, + { url = "https://files.pythonhosted.org/packages/6c/6c/65a7a0207787ad39ad804af4da7f06a60149de19481d73d270b540657234/cramjam-2.11.0-cp314-cp314-musllinux_1_1_i686.whl", hash = "sha256:d87d37b3d476f4f7623c56a232045d25bd9b988314702ea01bd9b4a94948a778", size = 2170839, upload-time = "2025-07-27T21:23:37.197Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c5/5c5db505ba692bc844246b066e23901d5905a32baf2f33719c620e65887f/cramjam-2.11.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:26cb45c47d71982d76282e303931c6dd4baee1753e5d48f9a89b3a63e690b3a3", size = 2157236, upload-time = "2025-07-27T21:23:38.854Z" }, + { url = "https://files.pythonhosted.org/packages/b0/22/88e6693e60afe98901e5bbe91b8dea193e3aa7f42e2770f9c3339f5c1065/cramjam-2.11.0-cp314-cp314-win32.whl", hash = "sha256:4efe919d443c2fd112fe25fe636a52f9628250c9a50d9bddb0488d8a6c09acc6", size = 1604136, upload-time = "2025-07-27T21:23:40.56Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f8/01618801cd59ccedcc99f0f96d20be67d8cfc3497da9ccaaad6b481781dd/cramjam-2.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ccec3524ea41b9abd5600e3e27001fd774199dbb4f7b9cb248fcee37d4bda84c", size = 1710272, upload-time = "2025-07-27T21:23:42.236Z" }, + { url = "https://files.pythonhosted.org/packages/40/81/6cdb3ed222d13ae86bda77aafe8d50566e81a1169d49ed195b6263610704/cramjam-2.11.0-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:966ac9358b23d21ecd895c418c048e806fd254e46d09b1ff0cdad2eba195ea3e", size = 3559671, upload-time = "2025-07-27T21:23:44.504Z" }, + { url = "https://files.pythonhosted.org/packages/cb/43/52b7e54fe5ba1ef0270d9fdc43dabd7971f70ea2d7179be918c997820247/cramjam-2.11.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:387f09d647a0d38dcb4539f8a14281f8eb6bb1d3e023471eb18a5974b2121c86", size = 1867876, upload-time = "2025-07-27T21:23:46.987Z" }, + { url = "https://files.pythonhosted.org/packages/9d/28/30d5b8d10acd30db3193bc562a313bff722888eaa45cfe32aa09389f2b24/cramjam-2.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:665b0d8fbbb1a7f300265b43926457ec78385200133e41fef19d85790fc1e800", size = 1695562, upload-time = "2025-07-27T21:23:48.644Z" }, + { url = "https://files.pythonhosted.org/packages/d9/86/ec806f986e01b896a650655024ea52a13e25c3ac8a3a382f493089483cdc/cramjam-2.11.0-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:ca905387c7a371531b9622d93471be4d745ef715f2890c3702479cd4fc85aa51", size = 2025056, upload-time = "2025-07-27T21:23:50.404Z" }, + { url = "https://files.pythonhosted.org/packages/09/43/c2c17586b90848d29d63181f7d14b8bd3a7d00975ad46e3edf2af8af7e1f/cramjam-2.11.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c1aa56aef2c8af55a21ed39040a94a12b53fb23beea290f94d19a76027e2ffb", size = 1764084, upload-time = "2025-07-27T21:23:52.265Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a9/68bc334fadb434a61df10071dc8606702aa4f5b6cdb2df62474fc21d2845/cramjam-2.11.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e5db59c1cdfaa2ab85cc988e602d6919495f735ca8a5fd7603608eb1e23c26d5", size = 1854859, upload-time = "2025-07-27T21:23:54.085Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4e/b48e67835b5811ec5e9cb2e2bcba9c3fd76dab3e732569fe801b542c6ca9/cramjam-2.11.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b1f893014f00fe5e89a660a032e813bf9f6d91de74cd1490cdb13b2b59d0c9a3", size = 2035970, upload-time = "2025-07-27T21:23:55.758Z" }, + { url = "https://files.pythonhosted.org/packages/c4/70/d2ac33d572b4d90f7f0f2c8a1d60fb48f06b128fdc2c05f9b49891bb0279/cramjam-2.11.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c26a1eb487947010f5de24943bd7c422dad955b2b0f8650762539778c380ca89", size = 2069320, upload-time = "2025-07-27T21:23:57.494Z" }, + { url = "https://files.pythonhosted.org/packages/1d/4c/85cec77af4a74308ba5fca8e296c4e2f80ec465c537afc7ab1e0ca2f9a00/cramjam-2.11.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d5c8bfb438d94e7b892d1426da5fc4b4a5370cc360df9b8d9d77c33b896c37e", size = 1982668, upload-time = "2025-07-27T21:23:59.126Z" }, + { url = "https://files.pythonhosted.org/packages/55/45/938546d1629e008cc3138df7c424ef892719b1796ff408a2ab8550032e5e/cramjam-2.11.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:cb1fb8c9337ab0da25a01c05d69a0463209c347f16512ac43be5986f3d1ebaf4", size = 2034028, upload-time = "2025-07-27T21:24:00.865Z" }, + { url = "https://files.pythonhosted.org/packages/01/76/b5a53e20505555f1640e66dcf70394bcf51a1a3a072aa18ea35135a0f9ed/cramjam-2.11.0-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:1f6449f6de52dde3e2f1038284910c8765a397a25e2d05083870f3f5e7fc682c", size = 2155513, upload-time = "2025-07-27T21:24:02.92Z" }, + { url = "https://files.pythonhosted.org/packages/84/12/8d3f6ceefae81bbe45a347fdfa2219d9f3ac75ebc304f92cd5fcb4fbddc5/cramjam-2.11.0-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:382dec4f996be48ed9c6958d4e30c2b89435d7c2c4dbf32480b3b8886293dd65", size = 2170035, upload-time = "2025-07-27T21:24:04.558Z" }, + { url = "https://files.pythonhosted.org/packages/4b/85/3be6f0a1398f976070672be64f61895f8839857618a2d8cc0d3ab529d3dc/cramjam-2.11.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:d388bd5723732c3afe1dd1d181e4213cc4e1be210b080572e7d5749f6e955656", size = 2160229, upload-time = "2025-07-27T21:24:06.729Z" }, + { url = "https://files.pythonhosted.org/packages/57/5e/66cfc3635511b20014bbb3f2ecf0095efb3049e9e96a4a9e478e4f3d7b78/cramjam-2.11.0-cp314-cp314t-win32.whl", hash = "sha256:0a70ff17f8e1d13f322df616505550f0f4c39eda62290acb56f069d4857037c8", size = 1610267, upload-time = "2025-07-27T21:24:08.428Z" }, + { url = "https://files.pythonhosted.org/packages/ce/c6/c71e82e041c95ffe6a92ac707785500aa2a515a4339c2c7dd67e3c449249/cramjam-2.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:028400d699442d40dbda02f74158c73d05cb76587a12490d0bfedd958fd49188", size = 1713108, upload-time = "2025-07-27T21:24:10.147Z" }, + { url = "https://files.pythonhosted.org/packages/8c/33/3d7a7fbfb313614d59ae2e512b9dacfc22efb07c20e4af7deb73d3409f7b/cramjam-2.11.0-cp39-cp39-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:2581e82dca742b55d8b1d7f33892394c06b057a74f2853ffcb0802dcddcbf694", size = 3559843, upload-time = "2025-07-27T21:24:11.928Z" }, + { url = "https://files.pythonhosted.org/packages/d4/b0/ccf09697df7fcc750c4913dc4bf3fb91e5b778dda65fb9fa55dde61c03dc/cramjam-2.11.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a9994a42cd12f07ece04eff94dbf6e127b3986f7af9b26db1eb4545c477a6604", size = 1862081, upload-time = "2025-07-27T21:24:13.8Z" }, + { url = "https://files.pythonhosted.org/packages/41/55/d36255f1a9004a3352469143d2b8a5b769e0eb4e484a8192da41ad67e893/cramjam-2.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a4963dac24213690183110d6b41125fdc4af871a5a213589d6c6606d49e1b949", size = 1699970, upload-time = "2025-07-27T21:24:15.547Z" }, + { url = "https://files.pythonhosted.org/packages/35/52/722a2efbe104903648185411f9c634e5678035476bc556001d6ef811e191/cramjam-2.11.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c9af16f0b07d851b968c54e52d19430d820bb47c26d10a09cfb5c7127de26773", size = 2025715, upload-time = "2025-07-27T21:24:17.327Z" }, + { url = "https://files.pythonhosted.org/packages/0a/60/75084f30277d5f2481d20a544654894a32528f98f4415c1bd467823ab5b2/cramjam-2.11.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e2400c09ba620e2ca91a903dbe907d75f6a1994d8337e9f3026778daa92b08d", size = 1766999, upload-time = "2025-07-27T21:24:19.163Z" }, + { url = "https://files.pythonhosted.org/packages/89/5c/2663bdfcea6ab06fcac97883b5b574a12236c5d9f70691cc05dd49cb10fb/cramjam-2.11.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b820004db8b22715cee2ef154d4b47b3d76c4677ff217c587dd46f694a3052f9", size = 1854352, upload-time = "2025-07-27T21:24:20.953Z" }, + { url = "https://files.pythonhosted.org/packages/b4/df/1db5b57ccf77e923687b2061766e69c2cbdaf41641204207dbf55ef7ebe9/cramjam-2.11.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:261e9200942189d8201a005ffa1e29339479364b5b0013ab0758b03229d9ac67", size = 2036219, upload-time = "2025-07-27T21:24:23.029Z" }, + { url = "https://files.pythonhosted.org/packages/f7/28/fa3b017668a3264068c893e57a6b923dfd8fa851a1c821c4cc1c95cd47a6/cramjam-2.11.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24c61f1fad56ca68aee53bf67b6a84cd762a2c71ee4b71064378547c2411ae6", size = 2077245, upload-time = "2025-07-27T21:24:25.127Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1d/6f6018ee81acec6c4ef6cda6bd0770959992caf2f1c41e7944a135a53eca/cramjam-2.11.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab86d22f69a21961f35d1a1b02278b5bb9a95c5f5b4722c6904bca343c8d219f", size = 1982235, upload-time = "2025-07-27T21:24:26.851Z" }, + { url = "https://files.pythonhosted.org/packages/31/b4/c38f6077d8ec7c9208d23d4f7f19a618f5b4940170c9deba5d3bdc722eb6/cramjam-2.11.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a88bc9b191422cd5b22a1521b28607008590628b6b2a8a7db5c54ec04dc82fa1", size = 2034629, upload-time = "2025-07-27T21:24:28.694Z" }, + { url = "https://files.pythonhosted.org/packages/66/3b/3f46a349b1a7a67e2bda10e99403e9163c87c95e34399cc69f4f86a2461a/cramjam-2.11.0-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:7855bc4df5ed5f7fb1c98ea3fd98292e9acd3c097b1b21d596a69e1e60455400", size = 2155552, upload-time = "2025-07-27T21:24:30.572Z" }, + { url = "https://files.pythonhosted.org/packages/ed/86/b431a51162d4c8f33b28bdcca047382e1038757d43625e65c8d29ed6c31f/cramjam-2.11.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:19eb43e21db9dc42613599703c1a8e40b0170514a313f11f4c8be380425a1019", size = 2169651, upload-time = "2025-07-27T21:24:32.331Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d5/9aa69784da58b6bd3f5abcaad2eb76ad2a89efde7929821bad17355fd8da/cramjam-2.11.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:cec977d673ad596bae6bdfc0091ee386cef05b515b23f2ce52f9fadd0156186a", size = 2159740, upload-time = "2025-07-27T21:24:34.108Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e1/75706936eb81605a939e15b8b7a1241b35e805ce76a64838b4586c440f61/cramjam-2.11.0-cp39-cp39-win32.whl", hash = "sha256:dcc3b15b97f3054964b47e2a5fcfb4f5ff569e9af0a7af19f1d4c5f4231bbf3b", size = 1605449, upload-time = "2025-07-27T21:24:36.538Z" }, + { url = "https://files.pythonhosted.org/packages/37/6b/ae7626994c7285bfc0ffa0d9929c3c16f2d0aea5b9e151dad82fd0616762/cramjam-2.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:5eb0603d8f8019451fc00e1daf4022dfc9df59c16d2e68f925c77ac94555493b", size = 1710860, upload-time = "2025-07-27T21:24:38.243Z" }, + { url = "https://files.pythonhosted.org/packages/bf/8f/82e35ec3c5387f1864f46b3c24bce89a07af8bb3ef242ae47281db2c1848/cramjam-2.11.0-pp310-pypy310_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:37bed927abc4a7ae2d2669baa3675e21904d8a038ed8e4313326ea7b3be62b2b", size = 3573104, upload-time = "2025-07-27T21:24:40.069Z" }, + { url = "https://files.pythonhosted.org/packages/f0/4e/0c821918080a32ba1e52c040e12dd02dada67728f07305c5f778b808a807/cramjam-2.11.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:50e4a58635fa8c6897d84847d6e065eb69f92811670fc5e9f2d9e3b6279a02b6", size = 1873441, upload-time = "2025-07-27T21:24:42.333Z" }, + { url = "https://files.pythonhosted.org/packages/a8/fd/848d077bf6abc4ce84273d8e3f3a70d61a2240519a339462f699d8acf829/cramjam-2.11.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:3d1ba626dd5f81f7f09bbf59f70b534e2b75e0d6582b056b7bd31b397f1c13e9", size = 1702589, upload-time = "2025-07-27T21:24:44.305Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1c/899818999bbdb59c601756b413e87d37fd65875d1315346c10e367bb3505/cramjam-2.11.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c71e140d5eb3145d61d59d0be0bf72f07cc4cf4b32cb136b09f712a3b1040f5f", size = 1773646, upload-time = "2025-07-27T21:24:46.495Z" }, + { url = "https://files.pythonhosted.org/packages/5f/26/c2813c5422c43b3dcd8b6645bc359f08870737c44325ee4accc18f24eee0/cramjam-2.11.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a6ed7926a5cca28edebad7d0fedd2ad492710ae3524d25fc59a2b20546d9ce1", size = 1994179, upload-time = "2025-07-27T21:24:49.131Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4f/af984f8d7f963f0301812cdd620ddcfd8276461ed7a786c0f89e82b14739/cramjam-2.11.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5eb4ed3cea945b164b0513fd491884993acac2153a27b93a84019c522e8eda82", size = 1714790, upload-time = "2025-07-27T21:24:51.045Z" }, + { url = "https://files.pythonhosted.org/packages/81/da/b3301962ccd6fce9fefa1ecd8ea479edaeaa38fadb1f34d5391d2587216a/cramjam-2.11.0-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:52d5db3369f95b27b9f3c14d067acb0b183333613363ed34268c9e04560f997f", size = 3573546, upload-time = "2025-07-27T21:24:52.944Z" }, + { url = "https://files.pythonhosted.org/packages/b6/c2/410ddb8ad4b9dfb129284666293cb6559479645da560f7077dc19d6bee9e/cramjam-2.11.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4820516366d455b549a44d0e2210ee7c4575882dda677564ce79092588321d54", size = 1873654, upload-time = "2025-07-27T21:24:54.958Z" }, + { url = "https://files.pythonhosted.org/packages/d5/99/f68a443c64f7ce7aff5bed369b0aa5b2fac668fa3dfd441837e316e97a1f/cramjam-2.11.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d9e5db525dc0a950a825202f84ee68d89a072479e07da98795a3469df942d301", size = 1702846, upload-time = "2025-07-27T21:24:57.124Z" }, + { url = "https://files.pythonhosted.org/packages/6c/02/0ff358ab773def1ee3383587906c453d289953171e9c92db84fdd01bf172/cramjam-2.11.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62ab4971199b2270005359cdc379bc5736071dc7c9a228581c5122d9ffaac50c", size = 1773683, upload-time = "2025-07-27T21:24:59.28Z" }, + { url = "https://files.pythonhosted.org/packages/e9/31/3298e15f87c9cf2aabdbdd90b153d8644cf989cb42a45d68a1b71e1f7aaf/cramjam-2.11.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24758375cc5414d3035ca967ebb800e8f24604ececcba3c67d6f0218201ebf2d", size = 1994136, upload-time = "2025-07-27T21:25:01.565Z" }, + { url = "https://files.pythonhosted.org/packages/c7/90/20d1747255f1ee69a412e319da51ea594c18cca195e7a4d4c713f045eff5/cramjam-2.11.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6c2eea545fef1065c7dd4eda991666fd9c783fbc1d226592ccca8d8891c02f23", size = 1714982, upload-time = "2025-07-27T21:25:05.79Z" }, +] + +[[package]] +name = "cryptography" +version = "47.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version <= '3.9'", +] +dependencies = [ + { name = "cffi", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.9' and platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version <= '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/98/40dfe932134bdcae4f6ab5927c87488754bf9eb79297d7e0070b78dd58e9/cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", size = 7912214, upload-time = "2026-04-24T19:53:03.864Z" }, + { url = "https://files.pythonhosted.org/packages/34/c6/2733531243fba725f58611b918056b277692f1033373dcc8bd01af1c05d4/cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", size = 4644617, upload-time = "2026-04-24T19:53:06.909Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/b27be1a670a9b87f855d211cf0e1174a5d721216b7616bd52d8581d912ed/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", size = 4668186, upload-time = "2026-04-24T19:53:09.053Z" }, + { url = "https://files.pythonhosted.org/packages/81/b9/8443cfe5d17d482d348cee7048acf502bb89a51b6382f06240fd290d4ca3/cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", size = 4651244, upload-time = "2026-04-24T19:53:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/5d/5e/13ed0cdd0eb88ba159d6dd5ebfece8cb901dbcf1ae5ac4072e28b55d3153/cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", size = 5252906, upload-time = "2026-04-24T19:53:13.532Z" }, + { url = "https://files.pythonhosted.org/packages/64/16/ed058e1df0f33d440217cd120d41d5dda9dd215a80b8187f68483185af82/cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", size = 4701842, upload-time = "2026-04-24T19:53:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3d30986b30fdbd9e969abbdf8ba00ed0618615144341faeb57f395a084fe/cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", size = 4289313, upload-time = "2026-04-24T19:53:17.755Z" }, + { url = "https://files.pythonhosted.org/packages/df/fd/32db38e3ad0cb331f0691cb4c7a8a6f176f679124dee746b3af6633db4d9/cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", size = 4650964, upload-time = "2026-04-24T19:53:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/86/53/5395d944dfd48cb1f67917f533c609c34347185ef15eb4308024c876f274/cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", size = 5207817, upload-time = "2026-04-24T19:53:22.498Z" }, + { url = "https://files.pythonhosted.org/packages/34/4f/e5711b28e1901f7d480a2b1b688b645aa4c77c73f10731ed17e7f7db3f0d/cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", size = 4701544, upload-time = "2026-04-24T19:53:24.356Z" }, + { url = "https://files.pythonhosted.org/packages/22/22/c8ddc25de3010fc8da447648f5a092c40e7a8fadf01dd6d255d9c0b9373d/cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", size = 4783536, upload-time = "2026-04-24T19:53:26.665Z" }, + { url = "https://files.pythonhosted.org/packages/66/b6/d4a68f4ea999c6d89e8498579cba1c5fcba4276284de7773b17e4fa69293/cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", size = 4926106, upload-time = "2026-04-24T19:53:28.686Z" }, + { url = "https://files.pythonhosted.org/packages/54/ed/5f524db1fade9c013aa618e1c99c6ed05e8ffc9ceee6cda22fed22dda3f4/cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", size = 3258581, upload-time = "2026-04-24T19:53:31.058Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dc/1b901990b174786569029f67542b3edf72ac068b6c3c8683c17e6a2f5363/cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", size = 3775309, upload-time = "2026-04-24T19:53:33.054Z" }, + { url = "https://files.pythonhosted.org/packages/14/88/7aa18ad9c11bc87689affa5ce4368d884b517502d75739d475fc6f4a03c7/cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0", size = 7904299, upload-time = "2026-04-24T19:53:35.003Z" }, + { url = "https://files.pythonhosted.org/packages/07/55/c18f75724544872f234678fdedc871391722cb34a2aee19faa9f63100bb2/cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7", size = 4631180, upload-time = "2026-04-24T19:53:37.517Z" }, + { url = "https://files.pythonhosted.org/packages/ee/65/31a5cc0eaca99cec5bafffe155d407115d96136bb161e8b49e0ef73f09a7/cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1", size = 4653529, upload-time = "2026-04-24T19:53:39.775Z" }, + { url = "https://files.pythonhosted.org/packages/e5/bc/641c0519a495f3bfd0421b48d7cd325c4336578523ccd76ea322b6c29c7a/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c", size = 4638570, upload-time = "2026-04-24T19:53:42.129Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f2/300327b0a47f6dc94dd8b71b57052aefe178bb51745073d73d80604f11ab/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829", size = 5238019, upload-time = "2026-04-24T19:53:44.577Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/5b5cf994391d4bf9d9c7efd4c66aabe4d95227256627f8fea6cff7dfadbd/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7", size = 4686832, upload-time = "2026-04-24T19:53:47.015Z" }, + { url = "https://files.pythonhosted.org/packages/dc/2c/ae950e28fd6475c852fc21a44db3e6b5bcc1261d1e370f2b6e42fa800fef/cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923", size = 4269301, upload-time = "2026-04-24T19:53:48.97Z" }, + { url = "https://files.pythonhosted.org/packages/67/fb/6a39782e150ffe5cc1b0018cb6ddc48bf7ca62b498d7539ffc8a758e977d/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab", size = 4638110, upload-time = "2026-04-24T19:53:51.011Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d7/0b3c71090a76e5c203164a47688b697635ece006dcd2499ab3a4dbd3f0bd/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736", size = 5194988, upload-time = "2026-04-24T19:53:52.962Z" }, + { url = "https://files.pythonhosted.org/packages/63/33/63a961498a9df51721ab578c5a2622661411fc520e00bd83b0cc64eb20c4/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7", size = 4686563, upload-time = "2026-04-24T19:53:55.274Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/5ee5b145248f92250de86145d1c1d6edebbd57a7fe7caa4dedb5d4cf06a1/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52", size = 4770094, upload-time = "2026-04-24T19:53:57.753Z" }, + { url = "https://files.pythonhosted.org/packages/92/43/21d220b2da5d517773894dacdcdb5c682c28d3fffce65548cb06e87d5501/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd", size = 4913811, upload-time = "2026-04-24T19:54:00.236Z" }, + { url = "https://files.pythonhosted.org/packages/31/98/dc4ad376ac5f1a1a7d4a83f7b0c6f2bcad36b5d2d8f30aeb482d3a7d9582/cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63", size = 3237158, upload-time = "2026-04-24T19:54:02.606Z" }, + { url = "https://files.pythonhosted.org/packages/bc/da/97f62d18306b5133468bc3f8cc73a3111e8cdc8cf8d3e69474d6e5fd2d1b/cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b", size = 3758706, upload-time = "2026-04-24T19:54:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e0/34/a4fae8ae7c3bc227460c9ae43f56abf1b911da0ec29e0ebac53bb0a4b6b7/cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", size = 7904072, upload-time = "2026-04-24T19:54:06.411Z" }, + { url = "https://files.pythonhosted.org/packages/01/64/d7b1e54fdb69f22d24a64bb3e88dc718b31c7fb10ef0b9691a3cf7eeea6e/cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", size = 4635767, upload-time = "2026-04-24T19:54:08.519Z" }, + { url = "https://files.pythonhosted.org/packages/8b/7b/cca826391fb2a94efdcdfe4631eb69306ee1cff0b22f664a412c90713877/cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", size = 4654350, upload-time = "2026-04-24T19:54:10.795Z" }, + { url = "https://files.pythonhosted.org/packages/4c/65/4b57bcc823f42a991627c51c2f68c9fd6eb1393c1756aac876cba2accae2/cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", size = 4643394, upload-time = "2026-04-24T19:54:13.275Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/2c5fbeea70adbbca2bbae865e1d605d6a4a7f8dbd9d33eaf69645087f06c/cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", size = 5225777, upload-time = "2026-04-24T19:54:15.18Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b8/ac57107ef32749d2b244e36069bb688792a363aaaa3acc9e3cf84c130315/cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", size = 4688771, upload-time = "2026-04-24T19:54:17.835Z" }, + { url = "https://files.pythonhosted.org/packages/56/fc/9f1de22ff8be99d991f240a46863c52d475404c408886c5a38d2b5c3bb26/cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", size = 4270753, upload-time = "2026-04-24T19:54:19.963Z" }, + { url = "https://files.pythonhosted.org/packages/00/68/d70c852797aa68e8e48d12e5a87170c43f67bb4a59403627259dd57d15de/cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", size = 4642911, upload-time = "2026-04-24T19:54:21.818Z" }, + { url = "https://files.pythonhosted.org/packages/a5/51/661cbee74f594c5d97ff82d34f10d5551c085ca4668645f4606ebd22bd5d/cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", size = 5181411, upload-time = "2026-04-24T19:54:24.376Z" }, + { url = "https://files.pythonhosted.org/packages/94/87/f2b6c374a82cf076cfa1416992ac8e8ec94d79facc37aec87c1a5cb72352/cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", size = 4688262, upload-time = "2026-04-24T19:54:26.946Z" }, + { url = "https://files.pythonhosted.org/packages/14/e2/8b7462f4acf21ec509616f0245018bb197194ab0b65c2ea21a0bdd53c0eb/cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", size = 4775506, upload-time = "2026-04-24T19:54:28.926Z" }, + { url = "https://files.pythonhosted.org/packages/70/75/158e494e4c08dc05e039da5bb48553826bd26c23930cf8d3cd5f21fa8921/cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", size = 4912060, upload-time = "2026-04-24T19:54:30.869Z" }, + { url = "https://files.pythonhosted.org/packages/06/bd/0a9d3edbf5eadbac926d7b9b3cd0c4be584eeeae4a003d24d9eda4affbbd/cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", size = 3248487, upload-time = "2026-04-24T19:54:33.494Z" }, + { url = "https://files.pythonhosted.org/packages/60/80/5681af756d0da3a599b7bdb586fac5a1540f1bcefd2717a20e611ddade45/cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", size = 3755737, upload-time = "2026-04-24T19:54:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a0/928c9ce0d120a40a81aa99e3ba383e87337b9ac9ef9f6db02e4d7822424d/cryptography-47.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f1207974a904e005f762869996cf620e9bf79ecb4622f148550bb48e0eb35a7", size = 3909893, upload-time = "2026-04-24T19:54:38.334Z" }, + { url = "https://files.pythonhosted.org/packages/81/75/d691e284750df5d9569f2b1ce4a00a71e1d79566da83b2b3e5549c84917f/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:1a405c08857258c11016777e11c02bacbe7ef596faf259305d282272a3a05cbe", size = 4587867, upload-time = "2026-04-24T19:54:40.619Z" }, + { url = "https://files.pythonhosted.org/packages/07/d6/1b90f1a4e453009730b4545286f0b39bb348d805c11181fc31544e4f9a65/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:20fdbe3e38fb67c385d233c89371fa27f9909f6ebca1cecc20c13518dae65475", size = 4627192, upload-time = "2026-04-24T19:54:42.849Z" }, + { url = "https://files.pythonhosted.org/packages/dc/53/cb358a80e9e359529f496870dd08c102aa8a4b5b9f9064f00f0d6ed5b527/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f7db373287273d8af1414cf95dc4118b13ffdc62be521997b0f2b270771fef50", size = 4587486, upload-time = "2026-04-24T19:54:44.908Z" }, + { url = "https://files.pythonhosted.org/packages/8b/57/aaa3d53876467a226f9a7a82fd14dd48058ad2de1948493442dfa16e2ffd/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9fe6b7c64926c765f9dff301f9c1b867febcda5768868ca084e18589113732ab", size = 4626327, upload-time = "2026-04-24T19:54:47.813Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9c/51f28c3550276bcf35660703ba0ab829a90b88be8cd98a71ef23c2413913/cryptography-47.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8", size = 3698916, upload-time = "2026-04-24T19:54:49.782Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", + "python_full_version >= '3.9.12' and python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.9.12'", +] +dependencies = [ + { name = "cffi", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.9' and python_full_version < '3.10' and platform_python_implementation != 'PyPy'" }, + { name = "cffi", version = "2.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version > '3.9' and python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3e/e54cde8c01631a5a8226ccd617eab9e57fd5cfdad90f1a9e6bb570794631/cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c", size = 3963170, upload-time = "2026-07-31T14:24:51.968Z" }, + { url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" }, + { url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" }, + { url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" }, + { url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b5/c2c5fce26f0ee40d21bafe7f191d29a34b35a65ac4fe8a1191d1983612e9/cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", size = 3813796, upload-time = "2026-07-31T14:25:02.298Z" }, +] + +[[package]] +name = "cython" +version = "3.2.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/de/db48b8870e766cfea809986cc50c1e986c663a9ab7bafd0ac1a2512c4a26/cython-3.2.9.tar.gz", hash = "sha256:d249c9022ab13286b17bd66f30609e800c5f95efeecb06168990c7a66cecde6c", size = 3293493, upload-time = "2026-07-24T06:21:21.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/29/bc7e088201af74eacbb5799542d0f09d6e5e139cc55f19fd54c409f58109/cython-3.2.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2751b7c0cb13135aadcc1cc8fe223be98d458cd1132611d31675e768a5e4a2e9", size = 3000461, upload-time = "2026-07-24T06:21:37.078Z" }, + { url = "https://files.pythonhosted.org/packages/81/e8/ac8266fa88b3a80009a2fa55b2729be1518c05d182888223f95b8e2a2b8e/cython-3.2.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ccb0bc6b8437cfcb04459a02bc5ecc58bf161ea11659438945ab5a1392ee39fe", size = 3306728, upload-time = "2026-07-24T06:21:39.011Z" }, + { url = "https://files.pythonhosted.org/packages/df/9b/b4ff8cdff357ba0f41791e044a2a95898d0768b20e9247823decf5eabb5e/cython-3.2.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c2974742433be5c36ae1df1e761ce0063753fc2702316d5a3062b0a4e94a1df", size = 3458732, upload-time = "2026-07-24T06:21:41.034Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/06ba035f8b845d2d7d67f533a58ebd885ceab1e48b8cc442f093ecf59201/cython-3.2.9-cp310-cp310-win_amd64.whl", hash = "sha256:a3fc783d12202d1b064b6f125772d85f00e36e62eee6b2e415f56d8fd2d2e7b2", size = 2785625, upload-time = "2026-07-24T06:21:42.969Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/2f477d30fc6ca0ff333552233aa6dee0e57323a55913f84f24da9cac26fc/cython-3.2.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e8ec2d5a7b798c84d6779e7ca5318fd928b8fe9dd021106d714dc2e77cdc7555", size = 2992696, upload-time = "2026-07-24T06:21:44.931Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/0f280f7960c129957d2ce650e7fd7dec8e706c26d61a428e93927f4c9904/cython-3.2.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cac792b0bde1c86d8f832e513cd061b7e682181cc5a6d843d487e6c8ae9d5fcb", size = 3307656, upload-time = "2026-07-24T06:21:46.649Z" }, + { url = "https://files.pythonhosted.org/packages/ac/cd/6d7c35a1065f1a07c9fa5ec784ee562f32f36a8fd05748e5f10694887eb7/cython-3.2.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8c843dd85857cd0d0da055489f448d032866120cfb094ce16205a1ff54d06353", size = 3459259, upload-time = "2026-07-24T06:21:48.881Z" }, + { url = "https://files.pythonhosted.org/packages/9f/f6/c1ad54ec35fcd5c5a5808d8b5b6319d5ae96acc63f7b5c1f7812f8cc3f71/cython-3.2.9-cp311-cp311-win_amd64.whl", hash = "sha256:efd54fe07f808e7e82f6a04f370457ca02e770c948f0e2f83345ccc7ec8bb829", size = 2789027, upload-time = "2026-07-24T06:21:50.551Z" }, + { url = "https://files.pythonhosted.org/packages/fd/37/c74d842306c8fe381c415b37460d5e3086a820fac72b8ff5cb48513ccfcd/cython-3.2.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:114b2dee0fa1daa48a59574d848da0ff1b6bdb725a755e9b92fad14962e1ff8d", size = 3009571, upload-time = "2026-07-24T06:21:52.534Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/f7c42b161edd585e3ae556fd62a2c72cd80a6ed527a9907f0c5c6fb060de/cython-3.2.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5cd9c5f138cb052130b40ad3b6976d2180c35348410995812678f4636bd8f94", size = 3183562, upload-time = "2026-07-24T06:21:54.62Z" }, + { url = "https://files.pythonhosted.org/packages/56/1b/c04520ac7f3157aa12a69b632c16261170dac9fab6c48608cc004b8f1b17/cython-3.2.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23e80bc885c599e72072e18d0746df82d394b73100c1e153cda7359e6e59fe09", size = 3354811, upload-time = "2026-07-24T06:21:56.72Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/4ce33235a25b19fcd51dc639f0f403b783a3b7f9b1934eade0d993fbe029/cython-3.2.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b1fd5a9c03f72a18618668a8e90d569442ed742f910e3ad003dcc9348e9598b", size = 2778077, upload-time = "2026-07-24T06:21:58.7Z" }, + { url = "https://files.pythonhosted.org/packages/83/fa/7ffbaab88558e28411715ad35d913cac7d64ee965344c185325da84c4309/cython-3.2.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44b7fc417933d65bf31bce00337ff318efa3ea59daed21d10fe842d41e657c08", size = 2998816, upload-time = "2026-07-24T06:22:00.822Z" }, + { url = "https://files.pythonhosted.org/packages/04/69/feed68904f389452494d9e0861c24228c0bda656f9fbd32f9e5b525b2ef4/cython-3.2.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41637c644d7224d2d3170ee312077c2173693f0d4a0da1c82a0cffa3680a42dc", size = 3185802, upload-time = "2026-07-24T06:22:02.831Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0d/a7f094343e5c4925efac60e6dbdcf8602042e7f54c83ae14ccbc8033d2d9/cython-3.2.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f815cd3387bd88ca9eeabc357ce4bdf884eef0ab2949db0fbd5e0b69fe5ee422", size = 3360405, upload-time = "2026-07-24T06:22:04.645Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ba/a5c25226e29cb4f3a2c97d2b29de10e37c8a6b67b3c2f2a8b16ba713b218/cython-3.2.9-cp313-cp313-win_amd64.whl", hash = "sha256:ddb43637b7749b88644df4319e1c36f767e7fb71a92bb9942558c8de2f1cc5f4", size = 2774714, upload-time = "2026-07-24T06:22:06.953Z" }, + { url = "https://files.pythonhosted.org/packages/3c/69/e5969fa87c7b8833f62fe790e1617a1391112fa55fe12079c41a4553119c/cython-3.2.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:63a42e131112072f912b66734088abc946c6bc42cb7454461a6dfdd2d09d3bae", size = 3013040, upload-time = "2026-07-24T06:22:08.9Z" }, + { url = "https://files.pythonhosted.org/packages/b4/98/8eaceeec00d4f9cc75e3106397c005952e0d710332c4966215b8a110a522/cython-3.2.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f25b402a6f1eed34af2af57603a9aadcf594dacaced1655e925eb317fba3f337", size = 3231531, upload-time = "2026-07-24T06:22:11.028Z" }, + { url = "https://files.pythonhosted.org/packages/94/a4/1e03a0c115afa9de7b06891721d6489d6a05f78934a6897a0f5a409ee2f5/cython-3.2.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d3cba9c4770cf76370fead8d2ae71bc474f3ef67ae4119e455d025726bcdc1c", size = 3372818, upload-time = "2026-07-24T06:22:12.97Z" }, + { url = "https://files.pythonhosted.org/packages/1b/04/b5c4d76723824d84e3761718c51964f7829335ee1c7c16b69f11b111adf0/cython-3.2.9-cp314-cp314-win_amd64.whl", hash = "sha256:56d95c0674c25f281c6ae8f1d17bd425d6c2818bb304ff781831bb5d00d04b0b", size = 2812223, upload-time = "2026-07-24T06:22:14.9Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4a/342312c5fe021c8e0c386e1915d138e0902c48ae179b0374ab04773a8831/cython-3.2.9-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:944dc8747f640b3527649c566a5fc75ee0c15e80642ea2fdae4fe6378e1a9d4a", size = 2899729, upload-time = "2026-07-24T06:22:24.877Z" }, + { url = "https://files.pythonhosted.org/packages/f0/62/ea919ee426cb4d435ec8155e1ee6bcbb46b20d8f070527191b59769d4e7f/cython-3.2.9-cp39-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4b871ad97dd7fb1cbf56f6238c54423febd310afc1d9d9bc70c69c89b7ce57fc", size = 3226650, upload-time = "2026-07-24T06:22:26.947Z" }, + { url = "https://files.pythonhosted.org/packages/18/02/057b4f63e2ced8c3cf217c4e9fb544bfe48145f493347c7ca3f51607526c/cython-3.2.9-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9b6ebc6c74b4318eaa4e51e520dc8b95ebc7b262953c3ecb24131104681f14e", size = 2881919, upload-time = "2026-07-24T06:22:29.318Z" }, + { url = "https://files.pythonhosted.org/packages/64/e4/e158793ee3de7e4417ba17e7ff1015d6e2cf557cb485ad270b2446c9d1c7/cython-3.2.9-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:92989da161a7d18a7ad4baebc49289b2b77556d5a94916f90140ba26aecf6892", size = 3004702, upload-time = "2026-07-24T06:22:31.535Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/d5bbbd743ab4feddb24a7e823b34c3ec4ebab91ff503d16743a4e7ce106b/cython-3.2.9-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:e75ec625d8f8781ced690b7a2f5c2d138067711cf24bb8fb68c872c30c2fefe5", size = 2902695, upload-time = "2026-07-24T06:22:33.525Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/80850817395985259f135baa510d9186d3a325df81cd1862060bba977029/cython-3.2.9-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:2b1756ddc3bc0cd4341a515fc420c3e25e13c249f5537159b3fb0bff8d19e55c", size = 3241554, upload-time = "2026-07-24T06:22:35.667Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ce/6be776814f6cb81751f3da737ed537385738148e1ea99f89fb4637799198/cython-3.2.9-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7d41baea51ea00f9237f75af498577827493bca5e9b45bbd4e351543727e589a", size = 3124337, upload-time = "2026-07-24T06:22:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/e2/48/27c948cbbfe6050994e67a497ae530955dcda7e79084319321c49969e0fd/cython-3.2.9-cp39-abi3-win32.whl", hash = "sha256:61d4abbf84f77c8d19361d05d9f51d65d8d95e74f736eae55fa1aed8a1430469", size = 2435609, upload-time = "2026-07-24T06:22:40.005Z" }, + { url = "https://files.pythonhosted.org/packages/17/ef/cf0e1bd7542296f1752be63b027f90271448d8c8062eac66d8e44a79b883/cython-3.2.9-cp39-abi3-win_arm64.whl", hash = "sha256:57a6a78d14f7dd7d6062d9bca694e2a8c1c14113b6ceceea076abcd1161fdc5a", size = 2458025, upload-time = "2026-07-24T06:22:41.973Z" }, + { url = "https://files.pythonhosted.org/packages/73/00/10a03030fc623071be0e724c721db179584c699f167505d6adc3f878cf33/cython-3.2.9-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d15c4d4ba8a3ad284124384f769465bfba45512aaa4c81caea8a165f79fc042a", size = 3010547, upload-time = "2026-07-24T06:22:43.917Z" }, + { url = "https://files.pythonhosted.org/packages/e7/fa/7fd69af1d4e049d7abe7ff63c85e10887a4582d4c8c6295fbeda6c9cd882/cython-3.2.9-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f2fe4bc1b8fa4130563b94a3614db9ea17e92a455bed00e6a5d03eaff434b33c", size = 3314503, upload-time = "2026-07-24T06:22:46.115Z" }, + { url = "https://files.pythonhosted.org/packages/64/f0/c5bb19e524c3271f4dae9f581a113caee15f85f0960507952aacdb959297/cython-3.2.9-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c534d782757710e39f3c9582214d7a28a0d68c86e78ee3df1d729cbc270e133", size = 3466488, upload-time = "2026-07-24T06:22:48.326Z" }, + { url = "https://files.pythonhosted.org/packages/8c/36/5eecf542aebbd8d84b0144706b11c94a90876242e3f9592cf7edee6a870e/cython-3.2.9-cp39-cp39-win_amd64.whl", hash = "sha256:c6d4a92a87d238231564b5cbf27a8d1e46b7e62040c66b5ff33778a16b22295e", size = 2791440, upload-time = "2026-07-24T06:22:51.314Z" }, + { url = "https://files.pythonhosted.org/packages/00/ec/e61deec9bcfbb0e1b36f8b5ba75cb44644419b4bfd0fdd666bffd21d9579/cython-3.2.9-py3-none-any.whl", hash = "sha256:a2b0e87f6b80790c929308ca0831d686f7a180feab684fe8cd4a4380bd96aaca", size = 1259272, upload-time = "2026-07-24T06:21:18.95Z" }, +] + +[[package]] +name = "debtcollector" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9.12' and python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.9.12'", + "python_full_version <= '3.9'", +] +dependencies = [ + { name = "wrapt", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/e2/a45b5a620145937529c840df5e499c267997e85de40df27d54424a158d3c/debtcollector-3.0.0.tar.gz", hash = "sha256:2a8917d25b0e1f1d0d365d3c1c6ecfc7a522b1e9716e8a1a4a915126f7ccea6f", size = 31322, upload-time = "2024-02-22T15:39:20.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/ca/863ed8fa66d6f986de6ad7feccc5df96e37400845b1eeb29889a70feea99/debtcollector-3.0.0-py3-none-any.whl", hash = "sha256:46f9dacbe8ce49c47ebf2bf2ec878d50c9443dfae97cc7b8054be684e54c3e91", size = 23035, upload-time = "2024-02-22T15:39:18.99Z" }, +] + +[[package]] +name = "debtcollector" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "wrapt", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/57/1bbe02be744995408d944cf46b8c818cf072873064b1cd3c79c11618b216/debtcollector-3.1.0.tar.gz", hash = "sha256:278a45608cf16e79c0ae10851d869185c6b78f86610df8f27a451a18c1fec732", size = 32951, upload-time = "2026-03-24T10:07:38.202Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/05/3f36aed56f0e1815fdc2ed4a9f2bd680a7bfe8819f21eacded2dc00fe283/debtcollector-3.1.0-py3-none-any.whl", hash = "sha256:c64e49a66c0b71289620fc2fdf89c03d740bddb20576ddd4f04ddc01da946668", size = 24408, upload-time = "2026-03-24T10:07:37.218Z" }, +] + +[[package]] +name = "dnspython" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9.12' and python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.9.12'", + "python_full_version <= '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197, upload-time = "2024-10-05T20:14:59.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload-time = "2024-10-05T20:14:57.687Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "eventlet" +version = "0.40.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9.12' and python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.9.12'", + "python_full_version <= '3.9'", +] +dependencies = [ + { name = "dnspython", version = "2.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "greenlet", version = "3.2.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/d8/f72d8583db7c559445e0e9500a9b9787332370c16980802204a403634585/eventlet-0.40.4.tar.gz", hash = "sha256:69bef712b1be18b4930df6f0c495d2a882bf7b63aa111e7b6eeff461cfcaf26f", size = 565920, upload-time = "2025-11-26T13:57:31.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/6d/8e1fa901f6a8307f90e7bd932064e27a0062a4a7a16af38966a9c3293c52/eventlet-0.40.4-py3-none-any.whl", hash = "sha256:6326c6d0bf55810bece151f7a5750207c610f389ba110ffd1541ed6e5215485b", size = 364588, upload-time = "2025-11-26T13:57:29.09Z" }, +] + +[[package]] +name = "eventlet" +version = "0.41.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "dnspython", version = "2.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "greenlet", version = "3.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/03/9562f7aa854e001e3a63034c0a97590a1546e4fe530abf511c2ce07b0cb1/eventlet-0.41.1.tar.gz", hash = "sha256:e91010caa1880bb511de6ce2ed2186ef3493e0762a4d3ee93e97a0fcccdaaa28", size = 566159, upload-time = "2026-07-15T08:15:01.353Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/55/92d859c16a37a9b70e2c682747750184b389a6b4d25321a4a1ec48d94b33/eventlet-0.41.1-py3-none-any.whl", hash = "sha256:6f7bb5c2309d1c4527bf15fc2a5da0b829e68e495430b890993b47dfea258ae5", size = 364580, upload-time = "2026-07-15T08:14:59.536Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/4a/557715d5047da48d54e659203b9335be7bfaafda2c3f627b7c47e0b3aaf3/frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011", size = 86230, upload-time = "2025-10-06T05:35:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fb/c85f9fed3ea8fe8740e5b46a59cc141c23b842eca617da8876cfce5f760e/frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565", size = 49621, upload-time = "2025-10-06T05:35:25.341Z" }, + { url = "https://files.pythonhosted.org/packages/63/70/26ca3f06aace16f2352796b08704338d74b6d1a24ca38f2771afbb7ed915/frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad", size = 49889, upload-time = "2025-10-06T05:35:26.797Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ed/c7895fd2fde7f3ee70d248175f9b6cdf792fb741ab92dc59cd9ef3bd241b/frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2", size = 219464, upload-time = "2025-10-06T05:35:28.254Z" }, + { url = "https://files.pythonhosted.org/packages/6b/83/4d587dccbfca74cb8b810472392ad62bfa100bf8108c7223eb4c4fa2f7b3/frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186", size = 221649, upload-time = "2025-10-06T05:35:29.454Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/fd3b9cd046ec5fff9dab66831083bc2077006a874a2d3d9247dea93ddf7e/frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e", size = 219188, upload-time = "2025-10-06T05:35:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ce/80/6693f55eb2e085fc8afb28cf611448fb5b90e98e068fa1d1b8d8e66e5c7d/frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450", size = 231748, upload-time = "2025-10-06T05:35:32.101Z" }, + { url = "https://files.pythonhosted.org/packages/97/d6/e9459f7c5183854abd989ba384fe0cc1a0fb795a83c033f0571ec5933ca4/frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef", size = 236351, upload-time = "2025-10-06T05:35:33.834Z" }, + { url = "https://files.pythonhosted.org/packages/97/92/24e97474b65c0262e9ecd076e826bfd1d3074adcc165a256e42e7b8a7249/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4", size = 218767, upload-time = "2025-10-06T05:35:35.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/bf/dc394a097508f15abff383c5108cb8ad880d1f64a725ed3b90d5c2fbf0bb/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff", size = 235887, upload-time = "2025-10-06T05:35:36.354Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/25b201b9c015dbc999a5baf475a257010471a1fa8c200c843fd4abbee725/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c", size = 228785, upload-time = "2025-10-06T05:35:37.949Z" }, + { url = "https://files.pythonhosted.org/packages/84/f4/b5bc148df03082f05d2dd30c089e269acdbe251ac9a9cf4e727b2dbb8a3d/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f", size = 230312, upload-time = "2025-10-06T05:35:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/db/4b/87e95b5d15097c302430e647136b7d7ab2398a702390cf4c8601975709e7/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7", size = 217650, upload-time = "2025-10-06T05:35:40.377Z" }, + { url = "https://files.pythonhosted.org/packages/e5/70/78a0315d1fea97120591a83e0acd644da638c872f142fd72a6cebee825f3/frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a", size = 39659, upload-time = "2025-10-06T05:35:41.863Z" }, + { url = "https://files.pythonhosted.org/packages/66/aa/3f04523fb189a00e147e60c5b2205126118f216b0aa908035c45336e27e4/frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6", size = 43837, upload-time = "2025-10-06T05:35:43.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/75/1135feecdd7c336938bd55b4dc3b0dfc46d85b9be12ef2628574b28de776/frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e", size = 39989, upload-time = "2025-10-06T05:35:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/c2/59/ae5cdac87a00962122ea37bb346d41b66aec05f9ce328fa2b9e216f8967b/frozenlist-1.8.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d8b7138e5cd0647e4523d6685b0eac5d4be9a184ae9634492f25c6eb38c12a47", size = 86967, upload-time = "2025-10-06T05:37:55.607Z" }, + { url = "https://files.pythonhosted.org/packages/8a/10/17059b2db5a032fd9323c41c39e9d1f5f9d0c8f04d1e4e3e788573086e61/frozenlist-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a6483e309ca809f1efd154b4d37dc6d9f61037d6c6a81c2dc7a15cb22c8c5dca", size = 49984, upload-time = "2025-10-06T05:37:57.049Z" }, + { url = "https://files.pythonhosted.org/packages/4b/de/ad9d82ca8e5fa8f0c636e64606553c79e2b859ad253030b62a21fe9986f5/frozenlist-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1b9290cf81e95e93fdf90548ce9d3c1211cf574b8e3f4b3b7cb0537cf2227068", size = 50240, upload-time = "2025-10-06T05:37:58.145Z" }, + { url = "https://files.pythonhosted.org/packages/4e/45/3dfb7767c2a67d123650122b62ce13c731b6c745bc14424eea67678b508c/frozenlist-1.8.0-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:59a6a5876ca59d1b63af8cd5e7ffffb024c3dc1e9cf9301b21a2e76286505c95", size = 219472, upload-time = "2025-10-06T05:37:59.239Z" }, + { url = "https://files.pythonhosted.org/packages/0b/bf/5bf23d913a741b960d5c1dac7c1985d8a2a1d015772b2d18ea168b08e7ff/frozenlist-1.8.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6dc4126390929823e2d2d9dc79ab4046ed74680360fc5f38b585c12c66cdf459", size = 221531, upload-time = "2025-10-06T05:38:00.521Z" }, + { url = "https://files.pythonhosted.org/packages/d0/03/27ec393f3b55860859f4b74cdc8c2a4af3dbf3533305e8eacf48a4fd9a54/frozenlist-1.8.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:332db6b2563333c5671fecacd085141b5800cb866be16d5e3eb15a2086476675", size = 219211, upload-time = "2025-10-06T05:38:01.842Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ad/0fd00c404fa73fe9b169429e9a972d5ed807973c40ab6b3cf9365a33d360/frozenlist-1.8.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ff15928d62a0b80bb875655c39bf517938c7d589554cbd2669be42d97c2cb61", size = 231775, upload-time = "2025-10-06T05:38:03.384Z" }, + { url = "https://files.pythonhosted.org/packages/8a/c3/86962566154cb4d2995358bc8331bfc4ea19d07db1a96f64935a1607f2b6/frozenlist-1.8.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7bf6cdf8e07c8151fba6fe85735441240ec7f619f935a5205953d58009aef8c6", size = 236631, upload-time = "2025-10-06T05:38:04.609Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/6ffad161dbd83782d2c66dc4d378a9103b31770cb1e67febf43aea42d202/frozenlist-1.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:48e6d3f4ec5c7273dfe83ff27c91083c6c9065af655dc2684d2c200c94308bb5", size = 218632, upload-time = "2025-10-06T05:38:05.917Z" }, + { url = "https://files.pythonhosted.org/packages/58/b2/4677eee46e0a97f9b30735e6ad0bf6aba3e497986066eb68807ac85cf60f/frozenlist-1.8.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:1a7607e17ad33361677adcd1443edf6f5da0ce5e5377b798fba20fae194825f3", size = 235967, upload-time = "2025-10-06T05:38:07.614Z" }, + { url = "https://files.pythonhosted.org/packages/05/f3/86e75f8639c5a93745ca7addbbc9de6af56aebb930d233512b17e46f6493/frozenlist-1.8.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:5a3a935c3a4e89c733303a2d5a7c257ea44af3a56c8202df486b7f5de40f37e1", size = 228799, upload-time = "2025-10-06T05:38:08.845Z" }, + { url = "https://files.pythonhosted.org/packages/30/00/39aad3a7f0d98f5eb1d99a3c311215674ed87061aecee7851974b335c050/frozenlist-1.8.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:940d4a017dbfed9daf46a3b086e1d2167e7012ee297fef9e1c545c4d022f5178", size = 230566, upload-time = "2025-10-06T05:38:10.52Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4d/aa144cac44568d137846ddc4d5210fb5d9719eb1d7ec6fa2728a54b5b94a/frozenlist-1.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b9be22a69a014bc47e78072d0ecae716f5eb56c15238acca0f43d6eb8e4a5bda", size = 217715, upload-time = "2025-10-06T05:38:11.832Z" }, + { url = "https://files.pythonhosted.org/packages/64/4c/8f665921667509d25a0dd72540513bc86b356c95541686f6442a3283019f/frozenlist-1.8.0-cp39-cp39-win32.whl", hash = "sha256:1aa77cb5697069af47472e39612976ed05343ff2e84a3dcf15437b232cbfd087", size = 39933, upload-time = "2025-10-06T05:38:13.061Z" }, + { url = "https://files.pythonhosted.org/packages/79/bd/bcc926f87027fad5e59926ff12d136e1082a115025d33c032d1cd69ab377/frozenlist-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:7398c222d1d405e796970320036b1b563892b65809d9e5261487bb2c7f7b5c6a", size = 44121, upload-time = "2025-10-06T05:38:14.572Z" }, + { url = "https://files.pythonhosted.org/packages/4c/07/9c2e4eb7584af4b705237b971b89a4155a8e57599c4483a131a39256a9a0/frozenlist-1.8.0-cp39-cp39-win_arm64.whl", hash = "sha256:b4f3b365f31c6cd4af24545ca0a244a53688cad8834e32f56831c4923b50a103", size = 40312, upload-time = "2025-10-06T05:38:15.699Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "futurist" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9.12' and python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.9.12'", + "python_full_version <= '3.9'", +] +dependencies = [ + { name = "debtcollector", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/12/786f4aaf9d396d67b1b7b90f248ff994e916605d0751d08a0344a4a785a6/futurist-3.2.1.tar.gz", hash = "sha256:01dd4f30acdfbb2e2eb6091da565eded82d8cbaf6c48a36cc7f73c11cfa7fb3f", size = 49326, upload-time = "2025-08-29T15:06:57.733Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/5b/a4418215b594fa44dea7deae61fa406139e2e8acc6442d25f93d80c52c84/futurist-3.2.1-py3-none-any.whl", hash = "sha256:c76a1e7b2c6b264666740c3dffbdcf512bd9684b4b253a3068a0135b43729745", size = 40485, upload-time = "2025-08-29T15:06:56.476Z" }, +] + +[[package]] +name = "futurist" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "debtcollector", version = "3.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/35/45/b603e4f3f1c6bdec051ee533166c7f880c88bbf2c2ed0ac661861374302f/futurist-3.3.0.tar.gz", hash = "sha256:3b84fdce52eb5094b486d95b8b9b1117fdf040f364a96969fbc22df955f42558", size = 51902, upload-time = "2026-03-24T10:13:44.015Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/6b/29b561eef753e14c999931e2a5acf44a5623958d877d7265595b737dfc51/futurist-3.3.0-py3-none-any.whl", hash = "sha256:3ba50d57b6086e3ba3d8bf87402218ab9fc4e280592cf5a19a49c0b375c3a69d", size = 43106, upload-time = "2026-03-24T10:13:43.03Z" }, +] + +[[package]] +name = "futurist" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "debtcollector", version = "3.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/c3/d6df974b3b488606a1064b02094e90ba8a35efa8072ab403cca2b9e3c51d/futurist-3.4.0.tar.gz", hash = "sha256:ed00c6f4c815cce9549157e8ec28624b2f5ec83f9577b0dbe54f7516137d43d3", size = 52282, upload-time = "2026-06-26T13:33:57.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/51/96f2ef5038dbd9db345b26b5850c1ad52131e42d16ec6ae6ed8eb2fcada4/futurist-3.4.0-py3-none-any.whl", hash = "sha256:a4c16e5522c0d9726e1f8ee2129b91b5593d054949ea3799f256241bd241bed5", size = 43051, upload-time = "2026-06-26T13:33:56.254Z" }, +] + +[[package]] +name = "geomet" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "click", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/8c/dde022aa6747b114f6b14a7392871275dea8867e2bd26cddb80cc6d66620/geomet-1.1.0.tar.gz", hash = "sha256:51e92231a0ef6aaa63ac20c443377ba78a303fd2ecd179dc3567de79f3c11605", size = 28732, upload-time = "2023-11-14T15:43:36.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/90/3bc780df088d439714af8295196a4332a26559ae66fd99865e36f92efa9e/geomet-1.1.0-py3-none-any.whl", hash = "sha256:4372fe4e286a34acc6f2e9308284850bd8c4aa5bc12065e2abbd4995900db12f", size = 31522, upload-time = "2023-11-14T15:43:35.305Z" }, +] + +[[package]] +name = "gevent" +version = "26.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, + { name = "cffi", version = "2.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, + { name = "greenlet", version = "3.2.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and platform_python_implementation == 'CPython'" }, + { name = "greenlet", version = "3.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and platform_python_implementation == 'CPython'" }, + { name = "zope-event", version = "6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "zope-event", version = "6.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "zope-interface", version = "8.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "zope-interface", version = "8.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/5c/92002455a57cb3634383e2b822e3bccf409f43cde34528e46428971475cf/gevent-26.7.0.tar.gz", hash = "sha256:5b333a556e38a302b1b8c80525bef16d437e16f1e7767947789406841856a102", size = 6729213, upload-time = "2026-07-22T20:16:04.713Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/60/878d0cdef05d952ac7f17ffe385143fb0f3720afce0f6ff5ddbf7aac0342/gevent-26.7.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:80e98fc808bd9cc5c911d78a443d214bf0c8f96c9fdd296893df7e40364d5f37", size = 2198781, upload-time = "2026-07-22T16:48:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/69/79/6ce781b60049060e9d89b3d0fe60940353adeb39856aaad5ee925fd127e9/gevent-26.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bf4b946b47cc6fdbdf9221f891db9a44df92166435c027760ee7dbdfb4039adc", size = 2229318, upload-time = "2026-07-22T17:02:11.713Z" }, + { url = "https://files.pythonhosted.org/packages/6d/38/48898f35c2092d699b755b01918551358db72b554c63f553c8f027d2bf31/gevent-26.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:55ce0b7f87f9befcc788d77eb039b1de89a35f37afc31942e12c7ae090a563b8", size = 1700480, upload-time = "2026-07-22T16:26:16.794Z" }, + { url = "https://files.pythonhosted.org/packages/93/51/53370896942523c333699394ccad379d186648dbfb913f42ce094ddfb4b3/gevent-26.7.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:7f7143823ef99bc657534a2b6e8cbadedc910750cc0b4f4b4438a58d9fe43ab2", size = 1783048, upload-time = "2026-07-22T18:11:25.996Z" }, + { url = "https://files.pythonhosted.org/packages/ba/56/5a2cb36d75d3b626d6ffa116673b34442bf5afd6f7ab4b98e512c1b008d6/gevent-26.7.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:ca4019899830471910129968251c795c8aee59e225fd16326ae01c1f93f3cfa6", size = 1880257, upload-time = "2026-07-22T18:10:40.919Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ed/ee7eb2f03a38a4f33b0f327bab16d3158b3a794588a84dba739cbf4a3e68/gevent-26.7.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:e4042da317a96d12110831cc404855f0c501a5a5aa476a7a18c3b480a5a59233", size = 1819378, upload-time = "2026-07-22T18:29:06.444Z" }, + { url = "https://files.pythonhosted.org/packages/5e/aa/e2a202c03cff4f49bba54cc321c3afc29eee9d6fc452f4aa94d2f00e7d3d/gevent-26.7.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:5c97ca98e1aae427a267eae0fbfe8d0884327e6b1cd51fc2ef6642b8b0b82701", size = 2136837, upload-time = "2026-07-22T16:48:29.939Z" }, + { url = "https://files.pythonhosted.org/packages/ac/64/4892fbca47aa4e06b86aef96d6146bd61175b23b7db431ec7937d73b2f72/gevent-26.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5d5d1864bc3db92d1f82d1790395eda99f98b47fd9f7ec02c4e182d7828a8251", size = 1794058, upload-time = "2026-07-22T18:07:10.644Z" }, + { url = "https://files.pythonhosted.org/packages/21/23/90bb7d0c6f59d2973bb8f4bd3164be00e466823dea01568e0cb36f2afb77/gevent-26.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:15fd2d88ed5370f8084079758758df91f26d2f68575e1ee76fce604ddba83e5e", size = 2159797, upload-time = "2026-07-22T17:02:13.229Z" }, + { url = "https://files.pythonhosted.org/packages/a3/67/4d1e315ee3052530fa8537e0cdf1f9c3a6606740f7372f420c7d12d5cf82/gevent-26.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:514bda3fff741d7e5ab108ee1d31550a7f4b2fd3dc6e3b6f38dfb8685efdafaa", size = 1682414, upload-time = "2026-07-22T16:26:22.18Z" }, + { url = "https://files.pythonhosted.org/packages/02/b1/d1b1de89677ee39e641ad8501ed72c2b99f1ddba1c14c462675f40b37a66/gevent-26.7.0-cp311-cp311-win_arm64.whl", hash = "sha256:0f26f9a8c32ac0a73f6084c59b63deeacb350e7f1fee5301d95c5e0683a390d4", size = 1562794, upload-time = "2026-07-22T16:27:53.205Z" }, + { url = "https://files.pythonhosted.org/packages/2b/66/104590ad3a9e671b3ef77ad19c1cc50e7f1c8c220b27ddfaf34e5f88bd9c/gevent-26.7.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:92f256285fb43a57f152bd2e51a59cde1cd0b20869ae1e6da583b6beab88ed8a", size = 2953977, upload-time = "2026-07-22T16:23:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/64/07/350d87161378633714184828bdc57c66f9b525eca5249ad1294dc7f8cf58/gevent-26.7.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:0e4fea187c5df7168b9538b4f543fcb0fcbaeb93be3d6cd499c324652c740704", size = 1800960, upload-time = "2026-07-22T18:11:27.242Z" }, + { url = "https://files.pythonhosted.org/packages/c4/6c/ddfe298c2ecb1cfc72a03dd69ba751759f7e7835d3135f171b284eb09ed1/gevent-26.7.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:ce732fe08d0ea65de07eff6e46bade8ac6a6fdb65cc748c713f3d31ae122529e", size = 1900387, upload-time = "2026-07-22T18:10:42.973Z" }, + { url = "https://files.pythonhosted.org/packages/f9/89/2647bbbf1da35a1c271a54452e76065308cbaf684ee32a79f989ca4265f6/gevent-26.7.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:c25b3522072137aecf3389031039230190038f888e257f490b3897d0e0620f74", size = 1848046, upload-time = "2026-07-22T18:29:08.074Z" }, + { url = "https://files.pythonhosted.org/packages/31/52/4f9b4c536b5a0424e328d5a5466640d185020f1f0b08b2de0ca939cc0a0b/gevent-26.7.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:eaaa75c9014df3f8c310c64f53f1152af8c6be32e82734396bed91e1d0e6f35c", size = 2132200, upload-time = "2026-07-22T16:48:31.203Z" }, + { url = "https://files.pythonhosted.org/packages/42/88/1daffa63b257c381df68018e4d3da72b429955cf95a171a46d3220422c8d/gevent-26.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f1956032a9926ac9b4152b2a50bc5a2cc020722ec16928ccaf32e227ee0aae47", size = 1814237, upload-time = "2026-07-22T18:07:12.438Z" }, + { url = "https://files.pythonhosted.org/packages/73/4c/b996cdddca78eac435195cd97dff590c65a9e0052640bcb6f8b6f1e30b1d/gevent-26.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d989a1ad6cc54f5c69bb7304360f98b4fda80da2b773f1047db9fba61ae7379a", size = 2157938, upload-time = "2026-07-22T17:02:14.887Z" }, + { url = "https://files.pythonhosted.org/packages/08/fd/44419d7559a95e238ee45d29df30bad78a91d343cb27b13a6af552b907bb/gevent-26.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:e0c9ce2d80fc0f8894d748a1045ff26ad188e294bad656b29839271800827c85", size = 1685319, upload-time = "2026-07-22T16:26:13.954Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7a/7df1762ccd9a40ce1ca626f50cb7a75758f2c930aabcc73c91cf00cb3448/gevent-26.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:959effe0c56cdee0bf761e5c4e78ab62880be147a2f2aa31112ca2f7e5754e53", size = 1558945, upload-time = "2026-07-22T16:26:56.737Z" }, + { url = "https://files.pythonhosted.org/packages/75/63/0fcfbe3f5696e56424f331ec41e0e447cea79c384848d6019e3f7f340f4b/gevent-26.7.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b1b89eb5566f75aa8b2bbdb0308e1ac8d9113ca7cff85b45366aea9faad639a1", size = 2976844, upload-time = "2026-07-22T16:24:39.275Z" }, + { url = "https://files.pythonhosted.org/packages/3c/6c/ea2d0afbe760c18df5bd1631dbe5a73d840d9b141cb71e6810157c2ae28a/gevent-26.7.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:449857ce058183442e2d71d83ff0c587a3ddff631e93c6d19a6dffb4814eccad", size = 1802332, upload-time = "2026-07-22T18:11:29.155Z" }, + { url = "https://files.pythonhosted.org/packages/59/90/36f2258f1bfe8601224f6159066103b832c31e1451ce8dc2cd2408b6ecf4/gevent-26.7.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:8260a3f38b05fcf3c283417b18617562dbec74f5784f748e4ba3866789d7f3a4", size = 1901253, upload-time = "2026-07-22T18:10:44.402Z" }, + { url = "https://files.pythonhosted.org/packages/dd/38/86dd67e5c2dfab016a9c935b4338d6cf8f9bfa72dc5a0f3fb879d993127a/gevent-26.7.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:30894398d06747b433c8923a6a77ede61259ce6822a99f6c6e7fa0216ccb73c3", size = 1850489, upload-time = "2026-07-22T18:29:09.694Z" }, + { url = "https://files.pythonhosted.org/packages/f1/33/f5651942a5967483298b6ce6f45572d33120dd0fd01c8991d3fca5b1e8ee/gevent-26.7.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:0b753522498118c9489753de7c612d4baed0edf384d9df2bf9492233ba1c20ff", size = 2129813, upload-time = "2026-07-22T16:48:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/c0/09/abe8217a8fcd3f0e94c9eec024a5499c0f267cb269ccea6c0e2e812319b9/gevent-26.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:055a643026dc28daff2be228555a2097937448cc9b58307edebcf81b9d78ff4b", size = 1815121, upload-time = "2026-07-22T18:07:13.988Z" }, + { url = "https://files.pythonhosted.org/packages/40/d6/dbae1cd2d27b62664cefa086035530eb21203d45b12f466272441c048c9c/gevent-26.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4e1dc6a2712de67fd210e1f1a408601f6908b042f6420e188106f2f37f94ec71", size = 2155913, upload-time = "2026-07-22T17:02:16.35Z" }, + { url = "https://files.pythonhosted.org/packages/fc/42/90b662f4eb27d7727d4619d5c6be872117f1a9f187b243ec7f6fef988ce7/gevent-26.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:44e5280296129c0915addaefdb37d6e9bc124a77a433b1b1c8ddf1853c53f4e7", size = 1682483, upload-time = "2026-07-22T16:26:30.492Z" }, + { url = "https://files.pythonhosted.org/packages/e1/84/7297c56b9fff463c4ba2f685dbb913a855df903046dc68d14e8655a29ffe/gevent-26.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f08b1aa6729f794409ca137e25f671e0d9bbda4451200c5e28a769375365388", size = 1556053, upload-time = "2026-07-22T16:26:14.365Z" }, + { url = "https://files.pythonhosted.org/packages/c1/bb/ab60d496cbdc0293ebbd6c2070b34da0632bd7a2ca20163c17e18d2d2dc9/gevent-26.7.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:0e0e3bf7ae0f82dbc5c6be26b4781e86c97f1e28d516b7a9746ac8b04bcc6948", size = 2992503, upload-time = "2026-07-22T16:24:36.503Z" }, + { url = "https://files.pythonhosted.org/packages/5c/35/75f27c06a82a5b22600aaccbd9567d89bb4091be43e96c02981f10aff23d/gevent-26.7.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:740050b53048207b080a1e183a377c47809ad0b7b7b0cd7eab0dea1045f7e480", size = 1809173, upload-time = "2026-07-22T18:11:30.724Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5f/a6b32b4db3fa76bd8a070f0f46f5306123bf6336e7a0ca0cd2f9b99473df/gevent-26.7.0-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:67983607eb6c7bafa362c5c43b69a27145b936c34a3d6441ed42413d62fae0a6", size = 1906630, upload-time = "2026-07-22T18:10:45.836Z" }, + { url = "https://files.pythonhosted.org/packages/e1/87/832495d8fcc05ff7432f038b7c4decbd5632425a2cd5da2ce73cb2d800c4/gevent-26.7.0-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:475848518d708e07d1987c3d94cb8ff53e2b3a69df32e39feda2779cafe400b0", size = 1855278, upload-time = "2026-07-22T18:29:11.517Z" }, + { url = "https://files.pythonhosted.org/packages/72/8b/2f36c0fa389fa2b7ceb5a8972b0e7da7bc770f9135315cf4246c607ca5fc/gevent-26.7.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:0f8ed457dd616bfe6682569f92730f9ab45aafb1aeca5e80eb2f6b9a2ce26d11", size = 2136155, upload-time = "2026-07-22T16:48:33.865Z" }, + { url = "https://files.pythonhosted.org/packages/b5/98/09f2cfaa23dbce48e3271e95b0d003f93acece6b5cfd40f4cebe3850d79b/gevent-26.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15373c68cf1fa14114bec2f09b16e2c65374bd5309e897e0a28740b09ce329e0", size = 1822108, upload-time = "2026-07-22T18:07:15.397Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d8/05a294165c17569f04284ad3c889684c8780544885b4cdf77b1432947d0c/gevent-26.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:73f3d53f2f390369e290c933b75bd87f1f2261f2f2f2175aa667c43ee3049bad", size = 2162814, upload-time = "2026-07-22T17:02:18.066Z" }, + { url = "https://files.pythonhosted.org/packages/59/89/58a545c4eda33e106d6887a0387adc2249abc14c779e3eb88bbfdf3768d6/gevent-26.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:f11b558d544ad2249029ba023cd6519ec3a0eee54a3d027e6515c1eaa322422a", size = 1706971, upload-time = "2026-07-22T16:27:02.263Z" }, + { url = "https://files.pythonhosted.org/packages/bb/cd/413f293e54961e5c89c54235370e3603ec0f561e7ace8357980410efbf78/gevent-26.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:3871f4ca59ec2328c3ef638a0fe01a28a825443a133368dc78eb5ceadcad7609", size = 1585078, upload-time = "2026-07-22T16:30:48.145Z" }, + { url = "https://files.pythonhosted.org/packages/21/3a/47f29f632aaa38aa12410f57f1732fc50bfd4d4006d2e7e022ce731cabc9/gevent-26.7.0-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:3e3d6e20a94239ad353b776e72b8ce18c35dbe4e98c279aef3932651553d8404", size = 2996208, upload-time = "2026-07-22T16:23:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b3/4620f1ce81ecec9890229806c73f07dd022e40f76a552bd430391e7316c4/gevent-26.7.0-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:ddbd3cc76b9bc69df651a216c2a62fc6415ad463b3ac9c6cbbbb8b7b8224af17", size = 1811545, upload-time = "2026-07-22T18:11:32.224Z" }, + { url = "https://files.pythonhosted.org/packages/97/fd/d285212ffd5585d511299e13e61d76262def8e826e9f20c92cb85df406f2/gevent-26.7.0-cp315-cp315-manylinux_2_28_ppc64le.whl", hash = "sha256:01ceab7e608dc1b9859d9511a0a29d7ce2e7d909ab19fddc860e70a2ed5b10ce", size = 1910418, upload-time = "2026-07-22T18:10:47.709Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e0/c5d666e6065652918cfb6e6a3cf8f721d0e152c57cec217ad81792a1323b/gevent-26.7.0-cp315-cp315-manylinux_2_28_s390x.whl", hash = "sha256:2e6c917b2b8baeb6080797a6b25e35e1fd784319a05bb92b87c53546e5578eb2", size = 1857891, upload-time = "2026-07-22T18:29:13.13Z" }, + { url = "https://files.pythonhosted.org/packages/a1/67/e945ed458fa98b34572876bfd0d35fe4fa3f1159f43660b71d982b7cb63e/gevent-26.7.0-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:df75a1748b26030f2f7f10042cc45640b22954d9d0dc6b4b6f0dbe0b6751a2d4", size = 2138121, upload-time = "2026-07-22T16:48:35.358Z" }, + { url = "https://files.pythonhosted.org/packages/99/55/622468fa1a3c4cf51f20e14813f5cc1592fe6e44a42ccca9195a0b18c769/gevent-26.7.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:ee1b389587e5d5c1eb19d0455b5b4d7a0fb5c5287af4e226ec66d9dfd2548107", size = 1825114, upload-time = "2026-07-22T18:07:16.667Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7a/151a2afcacf487ca25faf8b1bdd6c5b4ace2f7c1e6b4eaffe0a5e6e1df61/gevent-26.7.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c2918641ba756f46aa01ab9dd82d6dfceec403c77c2787298746b411dcf0288e", size = 2165990, upload-time = "2026-07-22T17:02:19.817Z" }, +] + +[[package]] +name = "greenlet" +version = "3.2.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9.12' and python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.9.12'", + "python_full_version <= '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/f5/3e9eafb4030588337b2a2ae4df46212956854e9069c07b53aa3caabafd47/greenlet-3.2.5.tar.gz", hash = "sha256:c816554eb33e7ecf9ba4defcb1fd8c994e59be6b4110da15480b3e7447ea4286", size = 191501, upload-time = "2026-02-20T20:08:51.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/d6/b3db928fc329b1b19ba32ffe143d2305f3aaafc583f5e1074c74ec445189/greenlet-3.2.5-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:34cc7cf8ab6f4b85298b01e13e881265ee7b3c1daf6bc10a2944abc15d4f87c3", size = 275803, upload-time = "2026-02-20T20:06:42.541Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ff/ab0ad4ff3d9e1faa266de4f6c79763b33fccd9265995f2940192494cc0ec/greenlet-3.2.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c11fe0cfb0ce33132f0b5d27eeadd1954976a82e5e9b60909ec2c4b884a55382", size = 633556, upload-time = "2026-02-20T20:30:41.594Z" }, + { url = "https://files.pythonhosted.org/packages/da/dd/7b3ac77099a1671af8077ecedb12c9a1be1310e4c35bb69fd34c18ab6093/greenlet-3.2.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:a145f4b1c4ed7a2c94561b7f18b4beec3d3fb6f0580db22f7ed1d544e0620b34", size = 644943, upload-time = "2026-02-20T20:37:23.084Z" }, + { url = "https://files.pythonhosted.org/packages/56/f0/bea7e7909ea9045b0c5055dad1ec9b81c82b761b4567e625f4f8349acfa1/greenlet-3.2.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:edbf4ab9a7057ee430a678fe2ef37ea5d69125d6bdc7feb42ed8d871c737e63b", size = 640849, upload-time = "2026-02-20T20:43:57.305Z" }, + { url = "https://files.pythonhosted.org/packages/0f/36/84630e9ff1dfc8b7690957c0f77834a84eabdbd9c4977c3a2d0cbd5325c2/greenlet-3.2.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc1d01bdd67db3e5711e6246e451d7a0f75fae7bbf40adde129296a7f9aa7cc9", size = 639841, upload-time = "2026-02-20T20:07:17.473Z" }, + { url = "https://files.pythonhosted.org/packages/12/c4/6a2ee6c676dea7a05a3c3c1291fbc8ea44f26456b0accc891471293825af/greenlet-3.2.5-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd593db7ee1fa8a513a48a404f8cc4126998a48025e3f5cbbc68d51be0a6bf66", size = 588813, upload-time = "2026-02-20T20:07:56.171Z" }, + { url = "https://files.pythonhosted.org/packages/01/c0/75e75c2c993aa850292561ec80f5c263e3924e5843aa95a38716df69304c/greenlet-3.2.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ac8db07bced2c39b987bba13a3195f8157b0cfbce54488f86919321444a1cc3c", size = 1117377, upload-time = "2026-02-20T20:32:48.452Z" }, + { url = "https://files.pythonhosted.org/packages/ee/03/e38ebf9024a0873fe8f60f5b7bc36bfb3be5e13efe4d798240f2d1f0fb73/greenlet-3.2.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4544ab2cfd5912e42458b13516429e029f87d8bbcdc8d5506db772941ae12493", size = 1141246, upload-time = "2026-02-20T20:06:23.576Z" }, + { url = "https://files.pythonhosted.org/packages/d8/7b/c6e1192c795c0c12871e199237909a6bd35757d92c8472c7c019959b8637/greenlet-3.2.5-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:acabf468466d18017e2ae5fbf1a5a88b86b48983e550e1ae1437b69a83d9f4ac", size = 276916, upload-time = "2026-02-20T20:06:18.166Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b6/9887b559f3e1952d23052ec352e9977e808a2246c7cb8282a38337221e88/greenlet-3.2.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:472841de62d60f2cafd60edd4fd4dd7253eb70e6eaf14b8990dcaf177f4af957", size = 636107, upload-time = "2026-02-20T20:30:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/8a/be/e3e48b63bbc27d660fa1d98aecb64906b90a12e686a436169c1330ef34b2/greenlet-3.2.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d951e7d628a6e8b68af469f0fe4f100ef64c4054abeb9cdafbfaa30a920c950", size = 648240, upload-time = "2026-02-20T20:37:24.608Z" }, + { url = "https://files.pythonhosted.org/packages/17/f6/2cbe999683f759f14f598234f04ae8ba6f22953a624b3a7a630003e6bfff/greenlet-3.2.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:87b791dd0e031a574249af717ac36f7031b18c35329561c1e0368201c18caf1f", size = 644170, upload-time = "2026-02-20T20:43:59.002Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ac/e731ed62576e91e533b36d0d97325adc2786674ab9e48ed8a6a24f4ef4e9/greenlet-3.2.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8317d732e2ae0935d9ed2af2ea876fa714cf6f3b887a31ca150b54329b0a6e9", size = 643313, upload-time = "2026-02-20T20:07:19.012Z" }, + { url = "https://files.pythonhosted.org/packages/70/64/99e5cdceb494bd4c1341c45b93f322601d2c8a5e1e4d1c7a2d24c5ed0570/greenlet-3.2.5-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce8aed6fdd5e07d3cbb988cbdc188266a4eb9e1a52db9ef5c6526e59962d3933", size = 591295, upload-time = "2026-02-20T20:07:57.286Z" }, + { url = "https://files.pythonhosted.org/packages/ee/e9/968e11f388c2b8792d3b8b40a57984c894a3b4745dae3662dce722653bc5/greenlet-3.2.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:60c06b502d56d5451f60ca665691da29f79ed95e247bcf8ce5024d7bbe64acb9", size = 1120277, upload-time = "2026-02-20T20:32:50.103Z" }, + { url = "https://files.pythonhosted.org/packages/cb/2c/b5f2c4c68d753dce08218dc5a6b21d82238fdfdc44309032f6fe24d285e6/greenlet-3.2.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d2a78e6f1bf3f1672df91e212a2f8314e1e7c922f065d14cbad4bc815059467", size = 1145746, upload-time = "2026-02-20T20:06:26.296Z" }, + { url = "https://files.pythonhosted.org/packages/ad/32/022b21523eee713e7550162d5ca6aed23f913cc2c6232b154b9fd9badc07/greenlet-3.2.5-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:2acb30e77042f747ca81f0a10cc153296567e92e666c5e1b117f4595afd43352", size = 278412, upload-time = "2026-02-20T20:03:15.02Z" }, + { url = "https://files.pythonhosted.org/packages/90/c5/8a3b0ed3cc34d8b988a44349437dfa0941f9c23ac108175f7b4ccea97111/greenlet-3.2.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:393c03c26c865f17f31d8db2f09603fadbe0581ad85a5d5908b131549fc38217", size = 644616, upload-time = "2026-02-20T20:30:44.823Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2c/2627bea183554695016af6cae93d7474fa90f61e5a6601a84ae7841cb720/greenlet-3.2.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:04e6a202cde56043fd355fefd1552c4caa5c087528121871d950eb4f1b51fa99", size = 658813, upload-time = "2026-02-20T20:37:26.255Z" }, + { url = "https://files.pythonhosted.org/packages/44/c6/a80fc96f7cca7962dd972875d12c52dfabc94cb02bfeb19f3e7e169fca44/greenlet-3.2.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d5583b2ffa677578a384337ee13125bdf9a427485d689014b39d638a4f3d8dbe", size = 653512, upload-time = "2026-02-20T20:44:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/2f/1b/75a5aeff487a26ba427a3837da6372f1fe6f2a9c6b2898e28ac99d491c11/greenlet-3.2.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:45fcea7b697b91290b36eafc12fff479aca6ba6500d98ef6f34d5634c7119cbe", size = 655426, upload-time = "2026-02-20T20:07:20.124Z" }, + { url = "https://files.pythonhosted.org/packages/53/91/9b5dfb4f3c88f8247c7a8f4c3759f0740bfa6bb0c59a9f6bf938e913df56/greenlet-3.2.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f96e2bb8a56b7e1aed1dbfbbe0050cb2ecca99c7c91892fd1771e3afab63b3e3", size = 611138, upload-time = "2026-02-20T20:07:58.966Z" }, + { url = "https://files.pythonhosted.org/packages/b4/8d/d0b086410512d9859c84e9242a9b341de9f5566011ddf3a3f6886b842b61/greenlet-3.2.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d7456e67b0be653dfe643bb37d9566cd30939c80f858e2ce6d2d54951f75b14a", size = 1126896, upload-time = "2026-02-20T20:32:52.198Z" }, + { url = "https://files.pythonhosted.org/packages/ef/37/59fe12fe456e84ced6ba71781e28cde52a3124d1dd2077bc1727021f49fd/greenlet-3.2.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5ceb29d1f74c7280befbbfa27b9bf91ba4a07a1a00b2179a5d953fc219b16c42", size = 1154779, upload-time = "2026-02-20T20:06:27.583Z" }, + { url = "https://files.pythonhosted.org/packages/dd/95/d5d332fb73affaf7a1fbe80e49c2c7eae4f17c645af24a3b3fa25736d6f0/greenlet-3.2.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:f2cc88b50b9006b324c1b9f5f3552f9d4564c78af57cdfb4c7baf4f0aa089146", size = 277166, upload-time = "2026-02-20T20:03:57.077Z" }, + { url = "https://files.pythonhosted.org/packages/6c/77/89458e20db5a4f1c64f9a0191561227e76d809941ca2d7529006d17d3450/greenlet-3.2.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e66872daffa360b2537170b73ad530f14fa31785b1bc78080125d92edf0a6def", size = 644674, upload-time = "2026-02-20T20:30:46.118Z" }, + { url = "https://files.pythonhosted.org/packages/90/f8/9962175d2f2eaa629a7fd7545abacc8c4deda3baa4e52c1526d2eb5f5546/greenlet-3.2.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c5445ddb7b586d870dad32ca9fc47c287d6022a528d194efdb8912093c5303ad", size = 658834, upload-time = "2026-02-20T20:37:27.466Z" }, + { url = "https://files.pythonhosted.org/packages/81/71/52c21a7106ce5218aa6fa59ec32825b2655f875a09b69f68bd3e5d01feb3/greenlet-3.2.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd904626b8779810062cb455514594776e3cba3b8c0ba4939894df9f7b384971", size = 653091, upload-time = "2026-02-20T20:44:01.927Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d7/826d0e080f0a7ad5ec47c8d143bbd3ca0887657bb806595fe2434d12938a/greenlet-3.2.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:752c896a8c976548faafe8a306d446c6a4c68d4fd24699b84d4393bd9ac69a8e", size = 655760, upload-time = "2026-02-20T20:07:21.551Z" }, + { url = "https://files.pythonhosted.org/packages/41/cc/33bd4c2f816be8c8e16f71740c4130adf3a66a3dd2ba29de72b9d8dd1096/greenlet-3.2.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:499b809e7738c8af0ff9ac9d5dd821cb93f4293065a9237543217f0b252f950a", size = 614132, upload-time = "2026-02-20T20:08:00.351Z" }, + { url = "https://files.pythonhosted.org/packages/48/79/f3891dcfc59097474a53cc3c624f2f2465e431ab493bda043b8c873fb20a/greenlet-3.2.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2c7429f6e9cea7cbf2637d86d3db12806ba970f7f972fcab39d6b54b4457cbaf", size = 1125286, upload-time = "2026-02-20T20:32:54.032Z" }, + { url = "https://files.pythonhosted.org/packages/ca/47/212b47e6d2d7a04c4083db1af2fdd291bc8fe99b7e3571bfa560b65fc361/greenlet-3.2.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a5e4b25e855800fba17713020c5c33e0a4b7a1829027719344f0c7c8870092a2", size = 1152825, upload-time = "2026-02-20T20:06:29Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9d/4e9b941be05f8da7ba804c6413761d2c11cca05994cbf0a015bd729419f0/greenlet-3.2.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7123b29e6bad2f3f89681be4ef316480fca798ebe8d22fbaced9cc3775007a4f", size = 277627, upload-time = "2026-02-20T20:06:04.798Z" }, + { url = "https://files.pythonhosted.org/packages/23/cb/a73625c9a35138330014ecf3740c0d62e0c2b5e7279bb7f2586b1b199fac/greenlet-3.2.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6e8fe0c72603201a86b2e038daf9b6c8570715f8779566419cff543b6ace88de", size = 690001, upload-time = "2026-02-20T20:30:47.754Z" }, + { url = "https://files.pythonhosted.org/packages/83/49/6d1531109507bce7dfb23acf57a87013627ed3ac058851176e443a6a9134/greenlet-3.2.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:050703a60603db0e817364d69e048c70af299040c13a7e67792b9e62d4571196", size = 702953, upload-time = "2026-02-20T20:37:29.125Z" }, + { url = "https://files.pythonhosted.org/packages/90/ac/6d8fff3b273fc60ad4b46f8411fe91c1e4cca064dfff68d096bc982fa6d0/greenlet-3.2.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:04633da773ae432649a3f092a8e4add390732cc9e1ab52c8ff2c91b8dc86f202", size = 698353, upload-time = "2026-02-20T20:44:03.547Z" }, + { url = "https://files.pythonhosted.org/packages/f7/38/f958ee90fab93529b30cc1e4a59b27c1112b640570043a84af84da3b3b98/greenlet-3.2.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6712bfd520530eb67331813f7112d3ee18e206f48b3d026d8a96cd2d2ad20251", size = 698995, upload-time = "2026-02-20T20:07:22.663Z" }, + { url = "https://files.pythonhosted.org/packages/51/c1/a603906e79716d61f08afedaf8aed62017661457aef233d62d6e57ecd511/greenlet-3.2.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bc06a78fa3ffbe2a75f1ebc7e040eacf6fa1050a9432953ab111fbbbf0d03c1", size = 661175, upload-time = "2026-02-20T20:08:01.477Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8f/f880ff4587d236b4d06893fb34da6b299aa0d00f6c8259673f80e1b6d63c/greenlet-3.2.5-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:dbe0e81e24982bb45907ca20152b31c2e3300ca352fdc4acbd4956e4a2cbc195", size = 274946, upload-time = "2026-02-20T20:05:21.979Z" }, + { url = "https://files.pythonhosted.org/packages/3c/50/f6c78b8420187fdfe97fcf2e6d1dd243a7742d272c32fd4d4b1095474b37/greenlet-3.2.5-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:15871afc0d78ec87d15d8412b337f287fc69f8f669346e391585824970931c48", size = 631781, upload-time = "2026-02-20T20:30:48.845Z" }, + { url = "https://files.pythonhosted.org/packages/26/d6/3277f92e1961e6e9f41d9f173ea74b5c1f7065072637669f761626f26cc0/greenlet-3.2.5-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5bf0d7d62e356ef2e87e55e46a4e930ac165f9372760fb983b5631bb479e9d3a", size = 643740, upload-time = "2026-02-20T20:37:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8a/c37b87659378759f158dbe03eaeb7ed002a8968f1c649b2972f5323f99b2/greenlet-3.2.5-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:e3f03ddd7142c758ab41c18089a1407b9959bd276b4e6dfbd8fd06403832c87a", size = 639098, upload-time = "2026-02-20T20:44:07.287Z" }, + { url = "https://files.pythonhosted.org/packages/2a/6a/4f79d2e7b5ef3723fc5ffea0d6cb22627e5f95e0f19c973fa12bf1cf7891/greenlet-3.2.5-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6dff6433742073e5b6ad40953a78a0e8cddcb3f6869e5ea635d29a810ca5e7d0", size = 638382, upload-time = "2026-02-20T20:07:23.883Z" }, + { url = "https://files.pythonhosted.org/packages/4d/59/7aadf33f23c65dbf4db27e7f5b60c414797a61e954352ae4a86c5c8b0553/greenlet-3.2.5-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdd67619cefe1cc9fcab57c8853d2bb36eca9f166c0058cc0d428d471f7c785c", size = 587516, upload-time = "2026-02-20T20:08:02.841Z" }, + { url = "https://files.pythonhosted.org/packages/1d/46/b3422959f830de28a4eea447414e6bd7b980d755892f66ab52ad805da1c4/greenlet-3.2.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:3828b309dfb1f117fe54867512a8265d8d4f00f8de6908eef9b885f4d8789062", size = 1115818, upload-time = "2026-02-20T20:32:55.786Z" }, + { url = "https://files.pythonhosted.org/packages/54/4a/3d1c9728f093415637cf3696909fa10852632e33e68238fb8ca60eb90de1/greenlet-3.2.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:67725ae9fea62c95cf1aa230f1b8d4dc38f7cd14f6103d1df8a5a95657eb8e54", size = 1140219, upload-time = "2026-02-20T20:06:30.334Z" }, +] + +[[package]] +name = "greenlet" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3f/9859f655d11901e7b2996c6e3d33e0caa9a1d4572c3bc61ed0faa64b2f4c/greenlet-3.3.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9bc885b89709d901859cf95179ec9f6bb67a3d2bb1f0e88456461bd4b7f8fd0d", size = 277747, upload-time = "2026-02-20T20:16:21.325Z" }, + { url = "https://files.pythonhosted.org/packages/fb/07/cb284a8b5c6498dbd7cba35d31380bb123d7dceaa7907f606c8ff5993cbf/greenlet-3.3.2-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b568183cf65b94919be4438dc28416b234b678c608cafac8874dfeeb2a9bbe13", size = 579202, upload-time = "2026-02-20T20:47:28.955Z" }, + { url = "https://files.pythonhosted.org/packages/ed/45/67922992b3a152f726163b19f890a85129a992f39607a2a53155de3448b8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:527fec58dc9f90efd594b9b700662ed3fb2493c2122067ac9c740d98080a620e", size = 590620, upload-time = "2026-02-20T20:55:55.581Z" }, + { url = "https://files.pythonhosted.org/packages/03/5f/6e2a7d80c353587751ef3d44bb947f0565ec008a2e0927821c007e96d3a7/greenlet-3.3.2-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508c7f01f1791fbc8e011bd508f6794cb95397fdb198a46cb6635eb5b78d85a7", size = 602132, upload-time = "2026-02-20T21:02:43.261Z" }, + { url = "https://files.pythonhosted.org/packages/ad/55/9f1ebb5a825215fadcc0f7d5073f6e79e3007e3282b14b22d6aba7ca6cb8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad0c8917dd42a819fe77e6bdfcb84e3379c0de956469301d9fd36427a1ca501f", size = 591729, upload-time = "2026-02-20T20:20:58.395Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/21f5455773d37f94b866eb3cf5caed88d6cea6dd2c6e1f9c34f463cba3ec/greenlet-3.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:97245cc10e5515dbc8c3104b2928f7f02b6813002770cfaffaf9a6e0fc2b94ef", size = 1551946, upload-time = "2026-02-20T20:49:31.102Z" }, + { url = "https://files.pythonhosted.org/packages/00/68/91f061a926abead128fe1a87f0b453ccf07368666bd59ffa46016627a930/greenlet-3.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8c1fdd7d1b309ff0da81d60a9688a8bd044ac4e18b250320a96fc68d31c209ca", size = 1618494, upload-time = "2026-02-20T20:21:06.541Z" }, + { url = "https://files.pythonhosted.org/packages/ac/78/f93e840cbaef8becaf6adafbaf1319682a6c2d8c1c20224267a5c6c8c891/greenlet-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:5d0e35379f93a6d0222de929a25ab47b5eb35b5ef4721c2b9cbcc4036129ff1f", size = 230092, upload-time = "2026-02-20T20:17:09.379Z" }, + { url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" }, + { url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" }, + { url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8b/1430a04657735a3f23116c2e0d5eb10220928846e4537a938a41b350bed6/greenlet-3.3.2-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4375a58e49522698d3e70cc0b801c19433021b5c37686f7ce9c65b0d5c8677d2", size = 605046, upload-time = "2026-02-20T21:02:45.234Z" }, + { url = "https://files.pythonhosted.org/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358", size = 594156, upload-time = "2026-02-20T20:20:59.955Z" }, + { url = "https://files.pythonhosted.org/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99", size = 1554649, upload-time = "2026-02-20T20:49:32.293Z" }, + { url = "https://files.pythonhosted.org/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be", size = 1619472, upload-time = "2026-02-20T20:21:07.966Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3a/efb2cf697fbccdf75b24e2c18025e7dfa54c4f31fab75c51d0fe79942cef/greenlet-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e692b2dae4cc7077cbb11b47d258533b48c8fde69a33d0d8a82e2fe8d8531d5", size = 230389, upload-time = "2026-02-20T20:17:18.772Z" }, + { url = "https://files.pythonhosted.org/packages/e1/a1/65bbc059a43a7e2143ec4fc1f9e3f673e04f9c7b371a494a101422ac4fd5/greenlet-3.3.2-cp311-cp311-win_arm64.whl", hash = "sha256:02b0a8682aecd4d3c6c18edf52bc8e51eacdd75c8eac52a790a210b06aa295fd", size = 229645, upload-time = "2026-02-20T20:18:18.695Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, + { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, + { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, + { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, + { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f", size = 231081, upload-time = "2026-02-20T20:17:28.121Z" }, + { url = "https://files.pythonhosted.org/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643", size = 230331, upload-time = "2026-02-20T20:17:23.34Z" }, + { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, + { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, + { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, + { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, + { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, + { url = "https://files.pythonhosted.org/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124", size = 230961, upload-time = "2026-02-20T20:16:58.461Z" }, + { url = "https://files.pythonhosted.org/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327", size = 230221, upload-time = "2026-02-20T20:17:37.152Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, + { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, +] + +[[package]] +name = "gremlinpython" +version = "3.7.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9.12' and python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.9.12'", + "python_full_version <= '3.9'", +] +dependencies = [ + { name = "aenum", marker = "python_full_version < '3.10'" }, + { name = "aiohttp", version = "3.13.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "async-timeout", marker = "python_full_version < '3.10'" }, + { name = "isodate", marker = "python_full_version < '3.10'" }, + { name = "nest-asyncio", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/67/a9f9b8380d9db4667f04a5e38c1725a337baf6d114a4b608b5faad45c632/gremlinpython-3.7.6.tar.gz", hash = "sha256:a13df1a47c493b8c4d5c9b79b6db5295080ee51b4ebf4987232a120b1b749b3c", size = 52704, upload-time = "2026-04-06T23:12:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/5d/a2a3016f7c902ab361a01df58949350a94622cb4985ca8304724866948f6/gremlinpython-3.7.6-py3-none-any.whl", hash = "sha256:14fdf6003ebed1b6463c321df67b1e9f4a4893b4e38c9d181cf26ded49a389d7", size = 73962, upload-time = "2026-04-06T23:12:03.803Z" }, +] + +[[package]] +name = "gremlinpython" +version = "3.8.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "aenum", marker = "python_full_version >= '3.10'" }, + { name = "aiohttp", version = "3.14.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "async-timeout", marker = "python_full_version >= '3.10'" }, + { name = "isodate", marker = "python_full_version >= '3.10'" }, + { name = "nest-asyncio", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/56/8867126fc3383ba2aea606e54079353d15568508cf7c60255f727780be8c/gremlinpython-3.8.1.tar.gz", hash = "sha256:23ad0ed694d63ef57611a04d3c648cc6bf05e86678959b6b6d921e83c48fd1f8", size = 53968, upload-time = "2026-04-07T00:22:20.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/55/f6adf83dd74563aca7721d456b1d33d7656448e29cc79a6aede3bb6ffa5b/gremlinpython-3.8.1-py3-none-any.whl", hash = "sha256:2e8136f9ea8cd771f9cc6f86f4ce73130595aed414a363534e1a4e18bfa81427", size = 75457, upload-time = "2026-04-07T00:22:18.776Z" }, +] + +[[package]] +name = "hyperlink" +version = "21.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/51/1947bd81d75af87e3bb9e34593a4cf118115a8feb451ce7a69044ef1412e/hyperlink-21.0.0.tar.gz", hash = "sha256:427af957daa58bc909471c6c40f74c5450fa123dd093fc53efd2e91d2705a56b", size = 140743, upload-time = "2021-01-08T05:51:20.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/aa/8caf6a0a3e62863cbb9dab27135660acba46903b703e224f14f447e57934/hyperlink-21.0.0-py2.py3-none-any.whl", hash = "sha256:e6b14c37ecb73e89c77d78cdb4c2cc8f3fb59a885c5b3f819ff4ed80f25af1b4", size = 74638, upload-time = "2021-01-08T05:51:22.906Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "incremental" +version = "24.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/3c/82e84109e02c492f382c711c58a3dd91badda6d746def81a1465f74dc9f5/incremental-24.11.0.tar.gz", hash = "sha256:87d3480dbb083c1d736222511a8cf380012a8176c2456d01ef483242abbbcf8c", size = 24000, upload-time = "2025-11-28T02:30:17.861Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/55/0f4df2a44053867ea9cbea73fc588b03c55605cd695cee0a3d86f0029cb2/incremental-24.11.0-py3-none-any.whl", hash = "sha256:a34450716b1c4341fe6676a0598e88a39e04189f4dce5dc96f656e040baa10b3", size = 21109, upload-time = "2025-11-28T02:30:16.442Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9.12' and python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.9.12'", + "python_full_version <= '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "isodate" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" }, +] + +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + +[[package]] +name = "kerberos" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/cd/f98699a6e806b9d974ea1d3376b91f09edcb90415adbf31e3b56ee99ba64/kerberos-1.3.1.tar.gz", hash = "sha256:cdd046142a4e0060f96a00eb13d82a5d9ebc0f2d7934393ed559bac773460a2c", size = 19126, upload-time = "2021-01-09T06:43:46.862Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/9a/d10386fa7da4588e61fdafdbac2953576f7de6f693d112c74f09a9749fb6/kerberos-1.3.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2002b3b1541fc51e2c081ee7048f55e5d9ca63dd09f0d7b951c263920db3a0bb", size = 20248, upload-time = "2021-01-09T06:43:45.915Z" }, +] + +[[package]] +name = "lz4" +version = "4.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/51/f1b86d93029f418033dddf9b9f79c8d2641e7454080478ee2aab5123173e/lz4-4.4.5.tar.gz", hash = "sha256:5f0b9e53c1e82e88c10d7c180069363980136b9d7a8306c4dca4f760d60c39f0", size = 172886, upload-time = "2025-11-03T13:02:36.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/45/2466d73d79e3940cad4b26761f356f19fd33f4409c96f100e01a5c566909/lz4-4.4.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d221fa421b389ab2345640a508db57da36947a437dfe31aeddb8d5c7b646c22d", size = 207396, upload-time = "2025-11-03T13:01:24.965Z" }, + { url = "https://files.pythonhosted.org/packages/72/12/7da96077a7e8918a5a57a25f1254edaf76aefb457666fcc1066deeecd609/lz4-4.4.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7dc1e1e2dbd872f8fae529acd5e4839efd0b141eaa8ae7ce835a9fe80fbad89f", size = 207154, upload-time = "2025-11-03T13:01:26.922Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0e/0fb54f84fd1890d4af5bc0a3c1fa69678451c1a6bd40de26ec0561bb4ec5/lz4-4.4.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e928ec2d84dc8d13285b4a9288fd6246c5cde4f5f935b479f50d986911f085e3", size = 1291053, upload-time = "2025-11-03T13:01:28.396Z" }, + { url = "https://files.pythonhosted.org/packages/15/45/8ce01cc2715a19c9e72b0e423262072c17d581a8da56e0bd4550f3d76a79/lz4-4.4.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daffa4807ef54b927451208f5f85750c545a4abbff03d740835fc444cd97f758", size = 1278586, upload-time = "2025-11-03T13:01:29.906Z" }, + { url = "https://files.pythonhosted.org/packages/6d/34/7be9b09015e18510a09b8d76c304d505a7cbc66b775ec0b8f61442316818/lz4-4.4.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a2b7504d2dffed3fd19d4085fe1cc30cf221263fd01030819bdd8d2bb101cf1", size = 1367315, upload-time = "2025-11-03T13:01:31.054Z" }, + { url = "https://files.pythonhosted.org/packages/2a/94/52cc3ec0d41e8d68c985ec3b2d33631f281d8b748fb44955bc0384c2627b/lz4-4.4.5-cp310-cp310-win32.whl", hash = "sha256:0846e6e78f374156ccf21c631de80967e03cc3c01c373c665789dc0c5431e7fc", size = 88173, upload-time = "2025-11-03T13:01:32.643Z" }, + { url = "https://files.pythonhosted.org/packages/ca/35/c3c0bdc409f551404355aeeabc8da343577d0e53592368062e371a3620e1/lz4-4.4.5-cp310-cp310-win_amd64.whl", hash = "sha256:7c4e7c44b6a31de77d4dc9772b7d2561937c9588a734681f70ec547cfbc51ecd", size = 99492, upload-time = "2025-11-03T13:01:33.813Z" }, + { url = "https://files.pythonhosted.org/packages/1d/02/4d88de2f1e97f9d05fd3d278fe412b08969bc94ff34942f5a3f09318144a/lz4-4.4.5-cp310-cp310-win_arm64.whl", hash = "sha256:15551280f5656d2206b9b43262799c89b25a25460416ec554075a8dc568e4397", size = 91280, upload-time = "2025-11-03T13:01:35.081Z" }, + { url = "https://files.pythonhosted.org/packages/93/5b/6edcd23319d9e28b1bedf32768c3d1fd56eed8223960a2c47dacd2cec2af/lz4-4.4.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d6da84a26b3aa5da13a62e4b89ab36a396e9327de8cd48b436a3467077f8ccd4", size = 207391, upload-time = "2025-11-03T13:01:36.644Z" }, + { url = "https://files.pythonhosted.org/packages/34/36/5f9b772e85b3d5769367a79973b8030afad0d6b724444083bad09becd66f/lz4-4.4.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:61d0ee03e6c616f4a8b69987d03d514e8896c8b1b7cc7598ad029e5c6aedfd43", size = 207146, upload-time = "2025-11-03T13:01:37.928Z" }, + { url = "https://files.pythonhosted.org/packages/04/f4/f66da5647c0d72592081a37c8775feacc3d14d2625bbdaabd6307c274565/lz4-4.4.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:33dd86cea8375d8e5dd001e41f321d0a4b1eb7985f39be1b6a4f466cd480b8a7", size = 1292623, upload-time = "2025-11-03T13:01:39.341Z" }, + { url = "https://files.pythonhosted.org/packages/85/fc/5df0f17467cdda0cad464a9197a447027879197761b55faad7ca29c29a04/lz4-4.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:609a69c68e7cfcfa9d894dc06be13f2e00761485b62df4e2472f1b66f7b405fb", size = 1279982, upload-time = "2025-11-03T13:01:40.816Z" }, + { url = "https://files.pythonhosted.org/packages/25/3b/b55cb577aa148ed4e383e9700c36f70b651cd434e1c07568f0a86c9d5fbb/lz4-4.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:75419bb1a559af00250b8f1360d508444e80ed4b26d9d40ec5b09fe7875cb989", size = 1368674, upload-time = "2025-11-03T13:01:42.118Z" }, + { url = "https://files.pythonhosted.org/packages/fb/31/e97e8c74c59ea479598e5c55cbe0b1334f03ee74ca97726e872944ed42df/lz4-4.4.5-cp311-cp311-win32.whl", hash = "sha256:12233624f1bc2cebc414f9efb3113a03e89acce3ab6f72035577bc61b270d24d", size = 88168, upload-time = "2025-11-03T13:01:43.282Z" }, + { url = "https://files.pythonhosted.org/packages/18/47/715865a6c7071f417bef9b57c8644f29cb7a55b77742bd5d93a609274e7e/lz4-4.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:8a842ead8ca7c0ee2f396ca5d878c4c40439a527ebad2b996b0444f0074ed004", size = 99491, upload-time = "2025-11-03T13:01:44.167Z" }, + { url = "https://files.pythonhosted.org/packages/14/e7/ac120c2ca8caec5c945e6356ada2aa5cfabd83a01e3170f264a5c42c8231/lz4-4.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:83bc23ef65b6ae44f3287c38cbf82c269e2e96a26e560aa551735883388dcc4b", size = 91271, upload-time = "2025-11-03T13:01:45.016Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ac/016e4f6de37d806f7cc8f13add0a46c9a7cfc41a5ddc2bc831d7954cf1ce/lz4-4.4.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:df5aa4cead2044bab83e0ebae56e0944cc7fcc1505c7787e9e1057d6d549897e", size = 207163, upload-time = "2025-11-03T13:01:45.895Z" }, + { url = "https://files.pythonhosted.org/packages/8d/df/0fadac6e5bd31b6f34a1a8dbd4db6a7606e70715387c27368586455b7fc9/lz4-4.4.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6d0bf51e7745484d2092b3a51ae6eb58c3bd3ce0300cf2b2c14f76c536d5697a", size = 207150, upload-time = "2025-11-03T13:01:47.205Z" }, + { url = "https://files.pythonhosted.org/packages/b7/17/34e36cc49bb16ca73fb57fbd4c5eaa61760c6b64bce91fcb4e0f4a97f852/lz4-4.4.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7b62f94b523c251cf32aa4ab555f14d39bd1a9df385b72443fd76d7c7fb051f5", size = 1292045, upload-time = "2025-11-03T13:01:48.667Z" }, + { url = "https://files.pythonhosted.org/packages/90/1c/b1d8e3741e9fc89ed3b5f7ef5f22586c07ed6bb04e8343c2e98f0fa7ff04/lz4-4.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c3ea562c3af274264444819ae9b14dbbf1ab070aff214a05e97db6896c7597e", size = 1279546, upload-time = "2025-11-03T13:01:50.159Z" }, + { url = "https://files.pythonhosted.org/packages/55/d9/e3867222474f6c1b76e89f3bd914595af69f55bf2c1866e984c548afdc15/lz4-4.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24092635f47538b392c4eaeff14c7270d2c8e806bf4be2a6446a378591c5e69e", size = 1368249, upload-time = "2025-11-03T13:01:51.273Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e7/d667d337367686311c38b580d1ca3d5a23a6617e129f26becd4f5dc458df/lz4-4.4.5-cp312-cp312-win32.whl", hash = "sha256:214e37cfe270948ea7eb777229e211c601a3e0875541c1035ab408fbceaddf50", size = 88189, upload-time = "2025-11-03T13:01:52.605Z" }, + { url = "https://files.pythonhosted.org/packages/a5/0b/a54cd7406995ab097fceb907c7eb13a6ddd49e0b231e448f1a81a50af65c/lz4-4.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:713a777de88a73425cf08eb11f742cd2c98628e79a8673d6a52e3c5f0c116f33", size = 99497, upload-time = "2025-11-03T13:01:53.477Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7e/dc28a952e4bfa32ca16fa2eb026e7a6ce5d1411fcd5986cd08c74ec187b9/lz4-4.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:a88cbb729cc333334ccfb52f070463c21560fca63afcf636a9f160a55fac3301", size = 91279, upload-time = "2025-11-03T13:01:54.419Z" }, + { url = "https://files.pythonhosted.org/packages/2f/46/08fd8ef19b782f301d56a9ccfd7dafec5fd4fc1a9f017cf22a1accb585d7/lz4-4.4.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6bb05416444fafea170b07181bc70640975ecc2a8c92b3b658c554119519716c", size = 207171, upload-time = "2025-11-03T13:01:56.595Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3f/ea3334e59de30871d773963997ecdba96c4584c5f8007fd83cfc8f1ee935/lz4-4.4.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b424df1076e40d4e884cfcc4c77d815368b7fb9ebcd7e634f937725cd9a8a72a", size = 207163, upload-time = "2025-11-03T13:01:57.721Z" }, + { url = "https://files.pythonhosted.org/packages/41/7b/7b3a2a0feb998969f4793c650bb16eff5b06e80d1f7bff867feb332f2af2/lz4-4.4.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:216ca0c6c90719731c64f41cfbd6f27a736d7e50a10b70fad2a9c9b262ec923d", size = 1292136, upload-time = "2025-11-03T13:02:00.375Z" }, + { url = "https://files.pythonhosted.org/packages/89/d1/f1d259352227bb1c185288dd694121ea303e43404aa77560b879c90e7073/lz4-4.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:533298d208b58b651662dd972f52d807d48915176e5b032fb4f8c3b6f5fe535c", size = 1279639, upload-time = "2025-11-03T13:02:01.649Z" }, + { url = "https://files.pythonhosted.org/packages/d2/fb/ba9256c48266a09012ed1d9b0253b9aa4fe9cdff094f8febf5b26a4aa2a2/lz4-4.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:451039b609b9a88a934800b5fc6ee401c89ad9c175abf2f4d9f8b2e4ef1afc64", size = 1368257, upload-time = "2025-11-03T13:02:03.35Z" }, + { url = "https://files.pythonhosted.org/packages/a5/6d/dee32a9430c8b0e01bbb4537573cabd00555827f1a0a42d4e24ca803935c/lz4-4.4.5-cp313-cp313-win32.whl", hash = "sha256:a5f197ffa6fc0e93207b0af71b302e0a2f6f29982e5de0fbda61606dd3a55832", size = 88191, upload-time = "2025-11-03T13:02:04.406Z" }, + { url = "https://files.pythonhosted.org/packages/18/e0/f06028aea741bbecb2a7e9648f4643235279a770c7ffaf70bd4860c73661/lz4-4.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:da68497f78953017deb20edff0dba95641cc86e7423dfadf7c0264e1ac60dc22", size = 99502, upload-time = "2025-11-03T13:02:05.886Z" }, + { url = "https://files.pythonhosted.org/packages/61/72/5bef44afb303e56078676b9f2486f13173a3c1e7f17eaac1793538174817/lz4-4.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:c1cfa663468a189dab510ab231aad030970593f997746d7a324d40104db0d0a9", size = 91285, upload-time = "2025-11-03T13:02:06.77Z" }, + { url = "https://files.pythonhosted.org/packages/49/55/6a5c2952971af73f15ed4ebfdd69774b454bd0dc905b289082ca8664fba1/lz4-4.4.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67531da3b62f49c939e09d56492baf397175ff39926d0bd5bd2d191ac2bff95f", size = 207348, upload-time = "2025-11-03T13:02:08.117Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d7/fd62cbdbdccc35341e83aabdb3f6d5c19be2687d0a4eaf6457ddf53bba64/lz4-4.4.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a1acbbba9edbcbb982bc2cac5e7108f0f553aebac1040fbec67a011a45afa1ba", size = 207340, upload-time = "2025-11-03T13:02:09.152Z" }, + { url = "https://files.pythonhosted.org/packages/77/69/225ffadaacb4b0e0eb5fd263541edd938f16cd21fe1eae3cd6d5b6a259dc/lz4-4.4.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a482eecc0b7829c89b498fda883dbd50e98153a116de612ee7c111c8bcf82d1d", size = 1293398, upload-time = "2025-11-03T13:02:10.272Z" }, + { url = "https://files.pythonhosted.org/packages/c6/9e/2ce59ba4a21ea5dc43460cba6f34584e187328019abc0e66698f2b66c881/lz4-4.4.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e099ddfaa88f59dd8d36c8a3c66bd982b4984edf127eb18e30bb49bdba68ce67", size = 1281209, upload-time = "2025-11-03T13:02:12.091Z" }, + { url = "https://files.pythonhosted.org/packages/80/4f/4d946bd1624ec229b386a3bc8e7a85fa9a963d67d0a62043f0af0978d3da/lz4-4.4.5-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2af2897333b421360fdcce895c6f6281dc3fab018d19d341cf64d043fc8d90d", size = 1369406, upload-time = "2025-11-03T13:02:13.683Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/d429ba4720a9064722698b4b754fb93e42e625f1318b8fe834086c7c783b/lz4-4.4.5-cp313-cp313t-win32.whl", hash = "sha256:66c5de72bf4988e1b284ebdd6524c4bead2c507a2d7f172201572bac6f593901", size = 88325, upload-time = "2025-11-03T13:02:14.743Z" }, + { url = "https://files.pythonhosted.org/packages/4b/85/7ba10c9b97c06af6c8f7032ec942ff127558863df52d866019ce9d2425cf/lz4-4.4.5-cp313-cp313t-win_amd64.whl", hash = "sha256:cdd4bdcbaf35056086d910d219106f6a04e1ab0daa40ec0eeef1626c27d0fddb", size = 99643, upload-time = "2025-11-03T13:02:15.978Z" }, + { url = "https://files.pythonhosted.org/packages/77/4d/a175459fb29f909e13e57c8f475181ad8085d8d7869bd8ad99033e3ee5fa/lz4-4.4.5-cp313-cp313t-win_arm64.whl", hash = "sha256:28ccaeb7c5222454cd5f60fcd152564205bcb801bd80e125949d2dfbadc76bbd", size = 91504, upload-time = "2025-11-03T13:02:17.313Z" }, + { url = "https://files.pythonhosted.org/packages/63/9c/70bdbdb9f54053a308b200b4678afd13efd0eafb6ddcbb7f00077213c2e5/lz4-4.4.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c216b6d5275fc060c6280936bb3bb0e0be6126afb08abccde27eed23dead135f", size = 207586, upload-time = "2025-11-03T13:02:18.263Z" }, + { url = "https://files.pythonhosted.org/packages/b6/cb/bfead8f437741ce51e14b3c7d404e3a1f6b409c440bad9b8f3945d4c40a7/lz4-4.4.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c8e71b14938082ebaf78144f3b3917ac715f72d14c076f384a4c062df96f9df6", size = 207161, upload-time = "2025-11-03T13:02:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/e7/18/b192b2ce465dfbeabc4fc957ece7a1d34aded0d95a588862f1c8a86ac448/lz4-4.4.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b5e6abca8df9f9bdc5c3085f33ff32cdc86ed04c65e0355506d46a5ac19b6e9", size = 1292415, upload-time = "2025-11-03T13:02:20.829Z" }, + { url = "https://files.pythonhosted.org/packages/67/79/a4e91872ab60f5e89bfad3e996ea7dc74a30f27253faf95865771225ccba/lz4-4.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b84a42da86e8ad8537aabef062e7f661f4a877d1c74d65606c49d835d36d668", size = 1279920, upload-time = "2025-11-03T13:02:22.013Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/d52c7b11eaa286d49dae619c0eec4aabc0bf3cda7a7467eb77c62c4471f3/lz4-4.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bba042ec5a61fa77c7e380351a61cb768277801240249841defd2ff0a10742f", size = 1368661, upload-time = "2025-11-03T13:02:23.208Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/137ddeea14c2cb86864838277b2607d09f8253f152156a07f84e11768a28/lz4-4.4.5-cp314-cp314-win32.whl", hash = "sha256:bd85d118316b53ed73956435bee1997bd06cc66dd2fa74073e3b1322bd520a67", size = 90139, upload-time = "2025-11-03T13:02:24.301Z" }, + { url = "https://files.pythonhosted.org/packages/18/2c/8332080fd293f8337779a440b3a143f85e374311705d243439a3349b81ad/lz4-4.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:92159782a4502858a21e0079d77cdcaade23e8a5d252ddf46b0652604300d7be", size = 101497, upload-time = "2025-11-03T13:02:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/ca/28/2635a8141c9a4f4bc23f5135a92bbcf48d928d8ca094088c962df1879d64/lz4-4.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:d994b87abaa7a88ceb7a37c90f547b8284ff9da694e6afcfaa8568d739faf3f7", size = 93812, upload-time = "2025-11-03T13:02:26.133Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/508f2ee73c126e4de53a3b8523ad14d666aeb00a6795425315f770dbf2f4/lz4-4.4.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f6538aaaedd091d6e5abdaa19b99e6e82697d67518f114721b5248709b639fad", size = 207384, upload-time = "2025-11-03T13:02:27.043Z" }, + { url = "https://files.pythonhosted.org/packages/64/84/da7fda86dcc7b6d40d45dd28201fc136adfc390815126db41411bf1e5205/lz4-4.4.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:13254bd78fef50105872989a2dc3418ff09aefc7d0765528adc21646a7288294", size = 207137, upload-time = "2025-11-03T13:02:28.021Z" }, + { url = "https://files.pythonhosted.org/packages/01/95/fb9c5bffed0f985eab70daf2087a94ad55cbbf83024175f39ff663f48b22/lz4-4.4.5-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e64e61f29cf95afb43549063d8433b46352baf0c8a70aa45e2585618fcf59d86", size = 1290508, upload-time = "2025-11-03T13:02:29.485Z" }, + { url = "https://files.pythonhosted.org/packages/57/6e/6a39b5ca9b9538cc9d61248c431065ad76cc0f10b40cb07d60b5bdde7750/lz4-4.4.5-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff1b50aeeec64df5603f17984e4b5be6166058dcf8f1e26a3da40d7a0f6ab547", size = 1278102, upload-time = "2025-11-03T13:02:30.878Z" }, + { url = "https://files.pythonhosted.org/packages/73/57/551a7f95825c9721d8bee4ec02d8b139b1a44796e63d09a737ca0d67b6b1/lz4-4.4.5-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1dd4d91d25937c2441b9fc0f4af01704a2d09f30a38c5798bc1d1b5a15ec9581", size = 1366651, upload-time = "2025-11-03T13:02:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/4f/85/daa1ae5695ce40924813257d7f5a8990ba5dd78a9170f912dd85c498f97c/lz4-4.4.5-cp39-cp39-win32.whl", hash = "sha256:d64141085864918392c3159cdad15b102a620a67975c786777874e1e90ef15ce", size = 88165, upload-time = "2025-11-03T13:02:33.413Z" }, + { url = "https://files.pythonhosted.org/packages/df/db/3e84e506fdd5e04c9e8564d30bb08b0f3103dd9a2fb863c86bd46accb99a/lz4-4.4.5-cp39-cp39-win_amd64.whl", hash = "sha256:f32b9e65d70f3684532358255dc053f143835c5f5991e28a5ac4c93ce94b9ea7", size = 99487, upload-time = "2025-11-03T13:02:34.246Z" }, + { url = "https://files.pythonhosted.org/packages/6a/85/40aa9d006fdebc4ae868c86ce2108a9453c2b524284817427de1284b5b00/lz4-4.4.5-cp39-cp39-win_arm64.whl", hash = "sha256:f9b8bde9909a010c75b3aea58ec3910393b758f3c219beed67063693df854db0", size = 91275, upload-time = "2025-11-03T13:02:35.117Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/0b/19348d4c98980c4851d2f943f8ebafdece2ae7ef737adcfa5994ce8e5f10/multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5", size = 77176, upload-time = "2026-01-26T02:42:59.784Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/9de3f8077852e3d438215c81e9b691244532d2e05b4270e89ce67b7d103c/multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8", size = 44996, upload-time = "2026-01-26T02:43:01.674Z" }, + { url = "https://files.pythonhosted.org/packages/31/5c/08c7f7fe311f32e83f7621cd3f99d805f45519cd06fafb247628b861da7d/multidict-6.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872", size = 44631, upload-time = "2026-01-26T02:43:03.169Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/0e3b1390ae772f27501199996b94b52ceeb64fe6f9120a32c6c3f6b781be/multidict-6.7.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991", size = 242561, upload-time = "2026-01-26T02:43:04.733Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f4/8719f4f167586af317b69dd3e90f913416c91ca610cac79a45c53f590312/multidict-6.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03", size = 242223, upload-time = "2026-01-26T02:43:06.695Z" }, + { url = "https://files.pythonhosted.org/packages/47/ab/7c36164cce64a6ad19c6d9a85377b7178ecf3b89f8fd589c73381a5eedfd/multidict-6.7.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981", size = 222322, upload-time = "2026-01-26T02:43:08.472Z" }, + { url = "https://files.pythonhosted.org/packages/f5/79/a25add6fb38035b5337bc5734f296d9afc99163403bbcf56d4170f97eb62/multidict-6.7.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6", size = 254005, upload-time = "2026-01-26T02:43:10.127Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7b/64a87cf98e12f756fc8bd444b001232ffff2be37288f018ad0d3f0aae931/multidict-6.7.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190", size = 251173, upload-time = "2026-01-26T02:43:11.731Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ac/b605473de2bb404e742f2cc3583d12aedb2352a70e49ae8fce455b50c5aa/multidict-6.7.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92", size = 243273, upload-time = "2026-01-26T02:43:13.063Z" }, + { url = "https://files.pythonhosted.org/packages/03/65/11492d6a0e259783720f3bc1d9ea55579a76f1407e31ed44045c99542004/multidict-6.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee", size = 238956, upload-time = "2026-01-26T02:43:14.843Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a7/7ee591302af64e7c196fb63fe856c788993c1372df765102bd0448e7e165/multidict-6.7.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2", size = 233477, upload-time = "2026-01-26T02:43:16.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/99/c109962d58756c35fd9992fed7f2355303846ea2ff054bb5f5e9d6b888de/multidict-6.7.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568", size = 243615, upload-time = "2026-01-26T02:43:17.84Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5f/1973e7c771c86e93dcfe1c9cc55a5481b610f6614acfc28c0d326fe6bfad/multidict-6.7.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40", size = 249930, upload-time = "2026-01-26T02:43:19.06Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a5/f170fc2268c3243853580203378cd522446b2df632061e0a5409817854c7/multidict-6.7.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962", size = 243807, upload-time = "2026-01-26T02:43:20.286Z" }, + { url = "https://files.pythonhosted.org/packages/de/01/73856fab6d125e5bc652c3986b90e8699a95e84b48d72f39ade6c0e74a8c/multidict-6.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505", size = 239103, upload-time = "2026-01-26T02:43:21.508Z" }, + { url = "https://files.pythonhosted.org/packages/e7/46/f1220bd9944d8aa40d8ccff100eeeee19b505b857b6f603d6078cb5315b0/multidict-6.7.1-cp310-cp310-win32.whl", hash = "sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122", size = 41416, upload-time = "2026-01-26T02:43:22.703Z" }, + { url = "https://files.pythonhosted.org/packages/68/00/9b38e272a770303692fc406c36e1a4c740f401522d5787691eb38a8925a8/multidict-6.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df", size = 46022, upload-time = "2026-01-26T02:43:23.77Z" }, + { url = "https://files.pythonhosted.org/packages/64/65/d8d42490c02ee07b6bbe00f7190d70bb4738b3cce7629aaf9f213ef730dd/multidict-6.7.1-cp310-cp310-win_arm64.whl", hash = "sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db", size = 43238, upload-time = "2026-01-26T02:43:24.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/74525ebe3eb5fddcd6735fc03cbea3feeed4122b53bc798ac32d297ac9ae/multidict-6.7.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:65573858d27cdeaca41893185677dc82395159aa28875a8867af66532d413a8f", size = 77107, upload-time = "2026-01-26T02:46:12.608Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9a/ce8744e777a74b3050b1bf56be3eed1053b3457302ea055f1ea437200a23/multidict-6.7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c524c6fb8fc342793708ab111c4dbc90ff9abd568de220432500e47e990c0358", size = 44943, upload-time = "2026-01-26T02:46:14.016Z" }, + { url = "https://files.pythonhosted.org/packages/83/9c/1d2a283d9c6f31e260cb6c2fccadc3edcf6c4c14ee0929cd2af4d2606dd7/multidict-6.7.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:aa23b001d968faef416ff70dc0f1ab045517b9b42a90edd3e9bcdb06479e31d5", size = 44603, upload-time = "2026-01-26T02:46:15.391Z" }, + { url = "https://files.pythonhosted.org/packages/87/9d/3b186201671583d8e8d6d79c07481a5aafd0ba7575e3d8566baec80c1e82/multidict-6.7.1-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6704fa2b7453b2fb121740555fa1ee20cd98c4d011120caf4d2b8d4e7c76eec0", size = 240573, upload-time = "2026-01-26T02:46:16.783Z" }, + { url = "https://files.pythonhosted.org/packages/42/7d/a52f5d4d0754311d1ac78478e34dff88de71259a8585e05ee14e5f877caf/multidict-6.7.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:121a34e5bfa410cdf2c8c49716de160de3b1dbcd86b49656f5681e4543bcd1a8", size = 240106, upload-time = "2026-01-26T02:46:18.432Z" }, + { url = "https://files.pythonhosted.org/packages/84/9f/d80118e6c30ff55b7d171bdc5520aad4b9626e657520b8d7c8ca8c2fad12/multidict-6.7.1-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:026d264228bcd637d4e060844e39cdc60f86c479e463d49075dedc21b18fbbe0", size = 219418, upload-time = "2026-01-26T02:46:20.526Z" }, + { url = "https://files.pythonhosted.org/packages/c7/bd/896e60b3457f194de77c7de64f9acce9f75da0518a5230ce1df534f6747b/multidict-6.7.1-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e697826df7eb63418ee190fd06ce9f1803593bb4b9517d08c60d9b9a7f69d8f", size = 252124, upload-time = "2026-01-26T02:46:22.157Z" }, + { url = "https://files.pythonhosted.org/packages/f4/de/ba6b30447c36a37078d0ba604aa12c1a52887af0c355236ca6e0a9d5286f/multidict-6.7.1-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb08271280173720e9fea9ede98e5231defcbad90f1624bea26f32ec8a956e2f", size = 249402, upload-time = "2026-01-26T02:46:23.718Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b2/50a383c96230e432895a2fd3bcfe1b65785899598259d871d5de6b93180c/multidict-6.7.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6b3228e1d80af737b72925ce5fb4daf5a335e49cd7ab77ed7b9fdfbf58c526e", size = 240346, upload-time = "2026-01-26T02:46:25.393Z" }, + { url = "https://files.pythonhosted.org/packages/89/37/16d391fd8da544b1489306e38a46785fa41dd0f0ef766837ed7d4676dde0/multidict-6.7.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3943debf0fbb57bdde5901695c11094a9a36723e5c03875f87718ee15ca2f4d2", size = 237010, upload-time = "2026-01-26T02:46:27.408Z" }, + { url = "https://files.pythonhosted.org/packages/b0/24/3152ee026eda86d5d3e3685182911e6951af7a016579da931080ce6ac9ad/multidict-6.7.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:98c5787b0a0d9a41d9311eae44c3b76e6753def8d8870ab501320efe75a6a5f8", size = 232018, upload-time = "2026-01-26T02:46:29.941Z" }, + { url = "https://files.pythonhosted.org/packages/9c/1f/48d3c27a72be7fd23a55d8847193c459959bf35a5bb5844530dab00b739b/multidict-6.7.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:08ccb2a6dc72009093ebe7f3f073e5ec5964cba9a706fa94b1a1484039b87941", size = 241498, upload-time = "2026-01-26T02:46:32.052Z" }, + { url = "https://files.pythonhosted.org/packages/1a/45/413643ae2952d0decdf6c1250f86d08a43e143271441e81027e38d598bd7/multidict-6.7.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb351f72c26dc9abe338ca7294661aa22969ad8ffe7ef7d5541d19f368dc854a", size = 247957, upload-time = "2026-01-26T02:46:33.666Z" }, + { url = "https://files.pythonhosted.org/packages/50/f8/f1d0ac23df15e0470776388bdb261506f63af1f81d28bacb5e262d6e12b6/multidict-6.7.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ac1c665bad8b5d762f5f85ebe4d94130c26965f11de70c708c75671297c776de", size = 241651, upload-time = "2026-01-26T02:46:35.7Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c9/1a2a18f383cf129add66b6c36b75c3911a7ba95cf26cb141482de085cc12/multidict-6.7.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fa6609d0364f4f6f58351b4659a1f3e0e898ba2a8c5cac04cb2c7bc556b0bc5", size = 236371, upload-time = "2026-01-26T02:46:37.37Z" }, + { url = "https://files.pythonhosted.org/packages/bb/aa/77d87e3fca31325b87e0eb72d5fe9a7472dcb51391a42df7ac1f3842f6c0/multidict-6.7.1-cp39-cp39-win32.whl", hash = "sha256:6f77ce314a29263e67adadc7e7c1bc699fcb3a305059ab973d038f87caa42ed0", size = 41426, upload-time = "2026-01-26T02:46:39.026Z" }, + { url = "https://files.pythonhosted.org/packages/e3/b3/e8863e6a2da15a9d7e98976ff402e871b7352c76566df6c18d0378e0d9cf/multidict-6.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:f537b55778cd3cbee430abe3131255d3a78202e0f9ea7ffc6ada893a4bcaeea4", size = 46180, upload-time = "2026-01-26T02:46:40.422Z" }, + { url = "https://files.pythonhosted.org/packages/93/d3/dd4fa951ad5b5fa216bf30054d705683d13405eea7459833d78f31b74c9c/multidict-6.7.1-cp39-cp39-win_arm64.whl", hash = "sha256:749aa54f578f2e5f439538706a475aa844bfa8ef75854b1401e6e528e4937cf9", size = 43231, upload-time = "2026-01-26T02:46:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + +[[package]] +name = "numpy" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9.12' and python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.9.12'", + "python_full_version <= '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/75/10dd1f8116a8b796cb2c737b674e02d02e80454bda953fa7e65d8c12b016/numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78", size = 18902015, upload-time = "2024-08-26T20:19:40.945Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/91/3495b3237510f79f5d81f2508f9f13fea78ebfdf07538fc7444badda173d/numpy-2.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:51129a29dbe56f9ca83438b706e2e69a39892b5eda6cedcb6b0c9fdc9b0d3ece", size = 21165245, upload-time = "2024-08-26T20:04:14.625Z" }, + { url = "https://files.pythonhosted.org/packages/05/33/26178c7d437a87082d11019292dce6d3fe6f0e9026b7b2309cbf3e489b1d/numpy-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f15975dfec0cf2239224d80e32c3170b1d168335eaedee69da84fbe9f1f9cd04", size = 13738540, upload-time = "2024-08-26T20:04:36.784Z" }, + { url = "https://files.pythonhosted.org/packages/ec/31/cc46e13bf07644efc7a4bf68df2df5fb2a1a88d0cd0da9ddc84dc0033e51/numpy-2.0.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:8c5713284ce4e282544c68d1c3b2c7161d38c256d2eefc93c1d683cf47683e66", size = 5300623, upload-time = "2024-08-26T20:04:46.491Z" }, + { url = "https://files.pythonhosted.org/packages/6e/16/7bfcebf27bb4f9d7ec67332ffebee4d1bf085c84246552d52dbb548600e7/numpy-2.0.2-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:becfae3ddd30736fe1889a37f1f580e245ba79a5855bff5f2a29cb3ccc22dd7b", size = 6901774, upload-time = "2024-08-26T20:04:58.173Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a3/561c531c0e8bf082c5bef509d00d56f82e0ea7e1e3e3a7fc8fa78742a6e5/numpy-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2da5960c3cf0df7eafefd806d4e612c5e19358de82cb3c343631188991566ccd", size = 13907081, upload-time = "2024-08-26T20:05:19.098Z" }, + { url = "https://files.pythonhosted.org/packages/fa/66/f7177ab331876200ac7563a580140643d1179c8b4b6a6b0fc9838de2a9b8/numpy-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:496f71341824ed9f3d2fd36cf3ac57ae2e0165c143b55c3a035ee219413f3318", size = 19523451, upload-time = "2024-08-26T20:05:47.479Z" }, + { url = "https://files.pythonhosted.org/packages/25/7f/0b209498009ad6453e4efc2c65bcdf0ae08a182b2b7877d7ab38a92dc542/numpy-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a61ec659f68ae254e4d237816e33171497e978140353c0c2038d46e63282d0c8", size = 19927572, upload-time = "2024-08-26T20:06:17.137Z" }, + { url = "https://files.pythonhosted.org/packages/3e/df/2619393b1e1b565cd2d4c4403bdd979621e2c4dea1f8532754b2598ed63b/numpy-2.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d731a1c6116ba289c1e9ee714b08a8ff882944d4ad631fd411106a30f083c326", size = 14400722, upload-time = "2024-08-26T20:06:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/22/ad/77e921b9f256d5da36424ffb711ae79ca3f451ff8489eeca544d0701d74a/numpy-2.0.2-cp310-cp310-win32.whl", hash = "sha256:984d96121c9f9616cd33fbd0618b7f08e0cfc9600a7ee1d6fd9b239186d19d97", size = 6472170, upload-time = "2024-08-26T20:06:50.361Z" }, + { url = "https://files.pythonhosted.org/packages/10/05/3442317535028bc29cf0c0dd4c191a4481e8376e9f0db6bcf29703cadae6/numpy-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:c7b0be4ef08607dd04da4092faee0b86607f111d5ae68036f16cc787e250a131", size = 15905558, upload-time = "2024-08-26T20:07:13.881Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cf/034500fb83041aa0286e0fb16e7c76e5c8b67c0711bb6e9e9737a717d5fe/numpy-2.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:49ca4decb342d66018b01932139c0961a8f9ddc7589611158cb3c27cbcf76448", size = 21169137, upload-time = "2024-08-26T20:07:45.345Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d9/32de45561811a4b87fbdee23b5797394e3d1504b4a7cf40c10199848893e/numpy-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:11a76c372d1d37437857280aa142086476136a8c0f373b2e648ab2c8f18fb195", size = 13703552, upload-time = "2024-08-26T20:08:06.666Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ca/2f384720020c7b244d22508cb7ab23d95f179fcfff33c31a6eeba8d6c512/numpy-2.0.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:807ec44583fd708a21d4a11d94aedf2f4f3c3719035c76a2bbe1fe8e217bdc57", size = 5298957, upload-time = "2024-08-26T20:08:15.83Z" }, + { url = "https://files.pythonhosted.org/packages/0e/78/a3e4f9fb6aa4e6fdca0c5428e8ba039408514388cf62d89651aade838269/numpy-2.0.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8cafab480740e22f8d833acefed5cc87ce276f4ece12fdaa2e8903db2f82897a", size = 6905573, upload-time = "2024-08-26T20:08:27.185Z" }, + { url = "https://files.pythonhosted.org/packages/a0/72/cfc3a1beb2caf4efc9d0b38a15fe34025230da27e1c08cc2eb9bfb1c7231/numpy-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a15f476a45e6e5a3a79d8a14e62161d27ad897381fecfa4a09ed5322f2085669", size = 13914330, upload-time = "2024-08-26T20:08:48.058Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a8/c17acf65a931ce551fee11b72e8de63bf7e8a6f0e21add4c937c83563538/numpy-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13e689d772146140a252c3a28501da66dfecd77490b498b168b501835041f951", size = 19534895, upload-time = "2024-08-26T20:09:16.536Z" }, + { url = "https://files.pythonhosted.org/packages/ba/86/8767f3d54f6ae0165749f84648da9dcc8cd78ab65d415494962c86fac80f/numpy-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9ea91dfb7c3d1c56a0e55657c0afb38cf1eeae4544c208dc465c3c9f3a7c09f9", size = 19937253, upload-time = "2024-08-26T20:09:46.263Z" }, + { url = "https://files.pythonhosted.org/packages/df/87/f76450e6e1c14e5bb1eae6836478b1028e096fd02e85c1c37674606ab752/numpy-2.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c1c9307701fec8f3f7a1e6711f9089c06e6284b3afbbcd259f7791282d660a15", size = 14414074, upload-time = "2024-08-26T20:10:08.483Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/0f0f328e1e59f73754f06e1adfb909de43726d4f24c6a3f8805f34f2b0fa/numpy-2.0.2-cp311-cp311-win32.whl", hash = "sha256:a392a68bd329eafac5817e5aefeb39038c48b671afd242710b451e76090e81f4", size = 6470640, upload-time = "2024-08-26T20:10:19.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/57/3a3f14d3a759dcf9bf6e9eda905794726b758819df4663f217d658a58695/numpy-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:286cd40ce2b7d652a6f22efdfc6d1edf879440e53e76a75955bc0c826c7e64dc", size = 15910230, upload-time = "2024-08-26T20:10:43.413Z" }, + { url = "https://files.pythonhosted.org/packages/45/40/2e117be60ec50d98fa08c2f8c48e09b3edea93cfcabd5a9ff6925d54b1c2/numpy-2.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:df55d490dea7934f330006d0f81e8551ba6010a5bf035a249ef61a94f21c500b", size = 20895803, upload-time = "2024-08-26T20:11:13.916Z" }, + { url = "https://files.pythonhosted.org/packages/46/92/1b8b8dee833f53cef3e0a3f69b2374467789e0bb7399689582314df02651/numpy-2.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8df823f570d9adf0978347d1f926b2a867d5608f434a7cff7f7908c6570dcf5e", size = 13471835, upload-time = "2024-08-26T20:11:34.779Z" }, + { url = "https://files.pythonhosted.org/packages/7f/19/e2793bde475f1edaea6945be141aef6c8b4c669b90c90a300a8954d08f0a/numpy-2.0.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9a92ae5c14811e390f3767053ff54eaee3bf84576d99a2456391401323f4ec2c", size = 5038499, upload-time = "2024-08-26T20:11:43.902Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ff/ddf6dac2ff0dd50a7327bcdba45cb0264d0e96bb44d33324853f781a8f3c/numpy-2.0.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:a842d573724391493a97a62ebbb8e731f8a5dcc5d285dfc99141ca15a3302d0c", size = 6633497, upload-time = "2024-08-26T20:11:55.09Z" }, + { url = "https://files.pythonhosted.org/packages/72/21/67f36eac8e2d2cd652a2e69595a54128297cdcb1ff3931cfc87838874bd4/numpy-2.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05e238064fc0610c840d1cf6a13bf63d7e391717d247f1bf0318172e759e692", size = 13621158, upload-time = "2024-08-26T20:12:14.95Z" }, + { url = "https://files.pythonhosted.org/packages/39/68/e9f1126d757653496dbc096cb429014347a36b228f5a991dae2c6b6cfd40/numpy-2.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0123ffdaa88fa4ab64835dcbde75dcdf89c453c922f18dced6e27c90d1d0ec5a", size = 19236173, upload-time = "2024-08-26T20:12:44.049Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e9/1f5333281e4ebf483ba1c888b1d61ba7e78d7e910fdd8e6499667041cc35/numpy-2.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:96a55f64139912d61de9137f11bf39a55ec8faec288c75a54f93dfd39f7eb40c", size = 19634174, upload-time = "2024-08-26T20:13:13.634Z" }, + { url = "https://files.pythonhosted.org/packages/71/af/a469674070c8d8408384e3012e064299f7a2de540738a8e414dcfd639996/numpy-2.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec9852fb39354b5a45a80bdab5ac02dd02b15f44b3804e9f00c556bf24b4bded", size = 14099701, upload-time = "2024-08-26T20:13:34.851Z" }, + { url = "https://files.pythonhosted.org/packages/d0/3d/08ea9f239d0e0e939b6ca52ad403c84a2bce1bde301a8eb4888c1c1543f1/numpy-2.0.2-cp312-cp312-win32.whl", hash = "sha256:671bec6496f83202ed2d3c8fdc486a8fc86942f2e69ff0e986140339a63bcbe5", size = 6174313, upload-time = "2024-08-26T20:13:45.653Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b5/4ac39baebf1fdb2e72585c8352c56d063b6126be9fc95bd2bb5ef5770c20/numpy-2.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:cfd41e13fdc257aa5778496b8caa5e856dc4896d4ccf01841daee1d96465467a", size = 15606179, upload-time = "2024-08-26T20:14:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/43/c1/41c8f6df3162b0c6ffd4437d729115704bd43363de0090c7f913cfbc2d89/numpy-2.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9059e10581ce4093f735ed23f3b9d283b9d517ff46009ddd485f1747eb22653c", size = 21169942, upload-time = "2024-08-26T20:14:40.108Z" }, + { url = "https://files.pythonhosted.org/packages/39/bc/fd298f308dcd232b56a4031fd6ddf11c43f9917fbc937e53762f7b5a3bb1/numpy-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:423e89b23490805d2a5a96fe40ec507407b8ee786d66f7328be214f9679df6dd", size = 13711512, upload-time = "2024-08-26T20:15:00.985Z" }, + { url = "https://files.pythonhosted.org/packages/96/ff/06d1aa3eeb1c614eda245c1ba4fb88c483bee6520d361641331872ac4b82/numpy-2.0.2-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:2b2955fa6f11907cf7a70dab0d0755159bca87755e831e47932367fc8f2f2d0b", size = 5306976, upload-time = "2024-08-26T20:15:10.876Z" }, + { url = "https://files.pythonhosted.org/packages/2d/98/121996dcfb10a6087a05e54453e28e58694a7db62c5a5a29cee14c6e047b/numpy-2.0.2-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:97032a27bd9d8988b9a97a8c4d2c9f2c15a81f61e2f21404d7e8ef00cb5be729", size = 6906494, upload-time = "2024-08-26T20:15:22.055Z" }, + { url = "https://files.pythonhosted.org/packages/15/31/9dffc70da6b9bbf7968f6551967fc21156207366272c2a40b4ed6008dc9b/numpy-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e795a8be3ddbac43274f18588329c72939870a16cae810c2b73461c40718ab1", size = 13912596, upload-time = "2024-08-26T20:15:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/b9/14/78635daab4b07c0930c919d451b8bf8c164774e6a3413aed04a6d95758ce/numpy-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b258c385842546006213344c50655ff1555a9338e2e5e02a0756dc3e803dd", size = 19526099, upload-time = "2024-08-26T20:16:11.048Z" }, + { url = "https://files.pythonhosted.org/packages/26/4c/0eeca4614003077f68bfe7aac8b7496f04221865b3a5e7cb230c9d055afd/numpy-2.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fec9451a7789926bcf7c2b8d187292c9f93ea30284802a0ab3f5be8ab36865d", size = 19932823, upload-time = "2024-08-26T20:16:40.171Z" }, + { url = "https://files.pythonhosted.org/packages/f1/46/ea25b98b13dccaebddf1a803f8c748680d972e00507cd9bc6dcdb5aa2ac1/numpy-2.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9189427407d88ff25ecf8f12469d4d39d35bee1db5d39fc5c168c6f088a6956d", size = 14404424, upload-time = "2024-08-26T20:17:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a6/177dd88d95ecf07e722d21008b1b40e681a929eb9e329684d449c36586b2/numpy-2.0.2-cp39-cp39-win32.whl", hash = "sha256:905d16e0c60200656500c95b6b8dca5d109e23cb24abc701d41c02d74c6b3afa", size = 6476809, upload-time = "2024-08-26T20:17:13.553Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2b/7fc9f4e7ae5b507c1a3a21f0f15ed03e794c1242ea8a242ac158beb56034/numpy-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:a3f4ab0caa7f053f6797fcd4e1e25caee367db3112ef2b6ef82d749530768c73", size = 15911314, upload-time = "2024-08-26T20:17:36.72Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3b/df5a870ac6a3be3a86856ce195ef42eec7ae50d2a202be1f5a4b3b340e14/numpy-2.0.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7f0a0c6f12e07fa94133c8a67404322845220c06a9e80e85999afe727f7438b8", size = 21025288, upload-time = "2024-08-26T20:18:07.732Z" }, + { url = "https://files.pythonhosted.org/packages/2c/97/51af92f18d6f6f2d9ad8b482a99fb74e142d71372da5d834b3a2747a446e/numpy-2.0.2-pp39-pypy39_pp73-macosx_14_0_x86_64.whl", hash = "sha256:312950fdd060354350ed123c0e25a71327d3711584beaef30cdaa93320c392d4", size = 6762793, upload-time = "2024-08-26T20:18:19.125Z" }, + { url = "https://files.pythonhosted.org/packages/12/46/de1fbd0c1b5ccaa7f9a005b66761533e2f6a3e560096682683a223631fe9/numpy-2.0.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26df23238872200f63518dd2aa984cfca675d82469535dc7162dc2ee52d9dd5c", size = 19334885, upload-time = "2024-08-26T20:18:47.237Z" }, + { url = "https://files.pythonhosted.org/packages/cc/dc/d330a6faefd92b446ec0f0dfea4c3207bb1fef3c4771d19cf4543efd2c78/numpy-2.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a46288ec55ebbd58947d31d72be2c63cbf839f0a63b49cb755022310792a3385", size = 15828784, upload-time = "2024-08-26T20:19:11.19Z" }, +] + +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" }, + { url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" }, + { url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" }, + { url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" }, + { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" }, + { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" }, + { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" }, + { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" }, + { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" }, + { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" }, + { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" }, + { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" }, + { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" }, + { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" }, + { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" }, + { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, +] + +[[package]] +name = "objgraph" +version = "3.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/74/60dfb345ca493d69551dd1ba599ceb6fe325527fedabe4217d6e030449e2/objgraph-3.6.2.tar.gz", hash = "sha256:00b9f2f40f7422e3c7f45a61c4dafdaf81f03ff0649d6eaec866f01030e51ad8", size = 759524, upload-time = "2024-10-10T12:00:45.207Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/67/7bffbb861cb8a0a62b7df50738d35812bf40dc8bcc1559c04bdf593f1164/objgraph-3.6.2-py3-none-any.whl", hash = "sha256:8114c97712291c3ba30d882406a384d0a7651b307ea9a06e0d83836ccde85e15", size = 17667, upload-time = "2024-10-10T12:00:41.581Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "propcache" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9.12' and python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.9.12'", + "python_full_version <= '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/0e/934b541323035566a9af292dba85a195f7b78179114f2c6ebb24551118a9/propcache-0.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c2d1fa3201efaf55d730400d945b5b3ab6e672e100ba0f9a409d950ab25d7db", size = 79534, upload-time = "2025-10-08T19:46:02.083Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6b/db0d03d96726d995dc7171286c6ba9d8d14251f37433890f88368951a44e/propcache-0.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1eb2994229cc8ce7fe9b3db88f5465f5fd8651672840b2e426b88cdb1a30aac8", size = 45526, upload-time = "2025-10-08T19:46:03.884Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c3/82728404aea669e1600f304f2609cde9e665c18df5a11cdd57ed73c1dceb/propcache-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:66c1f011f45a3b33d7bcb22daed4b29c0c9e2224758b6be00686731e1b46f925", size = 47263, upload-time = "2025-10-08T19:46:05.405Z" }, + { url = "https://files.pythonhosted.org/packages/df/1b/39313ddad2bf9187a1432654c38249bab4562ef535ef07f5eb6eb04d0b1b/propcache-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9a52009f2adffe195d0b605c25ec929d26b36ef986ba85244891dee3b294df21", size = 201012, upload-time = "2025-10-08T19:46:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/5b/01/f1d0b57d136f294a142acf97f4ed58c8e5b974c21e543000968357115011/propcache-0.4.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5d4e2366a9c7b837555cf02fb9be2e3167d333aff716332ef1b7c3a142ec40c5", size = 209491, upload-time = "2025-10-08T19:46:08.909Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c8/038d909c61c5bb039070b3fb02ad5cccdb1dde0d714792e251cdb17c9c05/propcache-0.4.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9d2b6caef873b4f09e26ea7e33d65f42b944837563a47a94719cc3544319a0db", size = 215319, upload-time = "2025-10-08T19:46:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/08/57/8c87e93142b2c1fa2408e45695205a7ba05fb5db458c0bf5c06ba0e09ea6/propcache-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b16ec437a8c8a965ecf95739448dd938b5c7f56e67ea009f4300d8df05f32b7", size = 196856, upload-time = "2025-10-08T19:46:12.003Z" }, + { url = "https://files.pythonhosted.org/packages/42/df/5615fec76aa561987a534759b3686008a288e73107faa49a8ae5795a9f7a/propcache-0.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:296f4c8ed03ca7476813fe666c9ea97869a8d7aec972618671b33a38a5182ef4", size = 193241, upload-time = "2025-10-08T19:46:13.495Z" }, + { url = "https://files.pythonhosted.org/packages/d5/21/62949eb3a7a54afe8327011c90aca7e03547787a88fb8bd9726806482fea/propcache-0.4.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1f0978529a418ebd1f49dad413a2b68af33f85d5c5ca5c6ca2a3bed375a7ac60", size = 190552, upload-time = "2025-10-08T19:46:14.938Z" }, + { url = "https://files.pythonhosted.org/packages/30/ee/ab4d727dd70806e5b4de96a798ae7ac6e4d42516f030ee60522474b6b332/propcache-0.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fd138803047fb4c062b1c1dd95462f5209456bfab55c734458f15d11da288f8f", size = 200113, upload-time = "2025-10-08T19:46:16.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0b/38b46208e6711b016aa8966a3ac793eee0d05c7159d8342aa27fc0bc365e/propcache-0.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8c9b3cbe4584636d72ff556d9036e0c9317fa27b3ac1f0f558e7e84d1c9c5900", size = 200778, upload-time = "2025-10-08T19:46:18.023Z" }, + { url = "https://files.pythonhosted.org/packages/cf/81/5abec54355ed344476bee711e9f04815d4b00a311ab0535599204eecc257/propcache-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f93243fdc5657247533273ac4f86ae106cc6445a0efacb9a1bfe982fcfefd90c", size = 193047, upload-time = "2025-10-08T19:46:19.449Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b6/1f237c04e32063cb034acd5f6ef34ef3a394f75502e72703545631ab1ef6/propcache-0.4.1-cp310-cp310-win32.whl", hash = "sha256:a0ee98db9c5f80785b266eb805016e36058ac72c51a064040f2bc43b61101cdb", size = 38093, upload-time = "2025-10-08T19:46:20.643Z" }, + { url = "https://files.pythonhosted.org/packages/a6/67/354aac4e0603a15f76439caf0427781bcd6797f370377f75a642133bc954/propcache-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:1cdb7988c4e5ac7f6d175a28a9aa0c94cb6f2ebe52756a3c0cda98d2809a9e37", size = 41638, upload-time = "2025-10-08T19:46:21.935Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e1/74e55b9fd1a4c209ff1a9a824bf6c8b3d1fc5a1ac3eabe23462637466785/propcache-0.4.1-cp310-cp310-win_arm64.whl", hash = "sha256:d82ad62b19645419fe79dd63b3f9253e15b30e955c0170e5cebc350c1844e581", size = 38229, upload-time = "2025-10-08T19:46:23.368Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d4/4e2c9aaf7ac2242b9358f98dccd8f90f2605402f5afeff6c578682c2c491/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", size = 80208, upload-time = "2025-10-08T19:46:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/c2/21/d7b68e911f9c8e18e4ae43bdbc1e1e9bbd971f8866eb81608947b6f585ff/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", size = 45777, upload-time = "2025-10-08T19:46:25.733Z" }, + { url = "https://files.pythonhosted.org/packages/d3/1d/11605e99ac8ea9435651ee71ab4cb4bf03f0949586246476a25aadfec54a/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", size = 47647, upload-time = "2025-10-08T19:46:27.304Z" }, + { url = "https://files.pythonhosted.org/packages/58/1a/3c62c127a8466c9c843bccb503d40a273e5cc69838805f322e2826509e0d/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566", size = 214929, upload-time = "2025-10-08T19:46:28.62Z" }, + { url = "https://files.pythonhosted.org/packages/56/b9/8fa98f850960b367c4b8fe0592e7fc341daa7a9462e925228f10a60cf74f/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165", size = 221778, upload-time = "2025-10-08T19:46:30.358Z" }, + { url = "https://files.pythonhosted.org/packages/46/a6/0ab4f660eb59649d14b3d3d65c439421cf2f87fe5dd68591cbe3c1e78a89/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc", size = 228144, upload-time = "2025-10-08T19:46:32.607Z" }, + { url = "https://files.pythonhosted.org/packages/52/6a/57f43e054fb3d3a56ac9fc532bc684fc6169a26c75c353e65425b3e56eef/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48", size = 210030, upload-time = "2025-10-08T19:46:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/40/e2/27e6feebb5f6b8408fa29f5efbb765cd54c153ac77314d27e457a3e993b7/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570", size = 208252, upload-time = "2025-10-08T19:46:35.309Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f8/91c27b22ccda1dbc7967f921c42825564fa5336a01ecd72eb78a9f4f53c2/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85", size = 202064, upload-time = "2025-10-08T19:46:36.993Z" }, + { url = "https://files.pythonhosted.org/packages/f2/26/7f00bd6bd1adba5aafe5f4a66390f243acab58eab24ff1a08bebb2ef9d40/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e", size = 212429, upload-time = "2025-10-08T19:46:38.398Z" }, + { url = "https://files.pythonhosted.org/packages/84/89/fd108ba7815c1117ddca79c228f3f8a15fc82a73bca8b142eb5de13b2785/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757", size = 216727, upload-time = "2025-10-08T19:46:39.732Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/3ec3f7e3173e73f1d600495d8b545b53802cbf35506e5732dd8578db3724/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f", size = 205097, upload-time = "2025-10-08T19:46:41.025Z" }, + { url = "https://files.pythonhosted.org/packages/61/b0/b2631c19793f869d35f47d5a3a56fb19e9160d3c119f15ac7344fc3ccae7/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1", size = 38084, upload-time = "2025-10-08T19:46:42.693Z" }, + { url = "https://files.pythonhosted.org/packages/f4/78/6cce448e2098e9f3bfc91bb877f06aa24b6ccace872e39c53b2f707c4648/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6", size = 41637, upload-time = "2025-10-08T19:46:43.778Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e9/754f180cccd7f51a39913782c74717c581b9cc8177ad0e949f4d51812383/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239", size = 38064, upload-time = "2025-10-08T19:46:44.872Z" }, + { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, + { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, + { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, + { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, + { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, + { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, + { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, + { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, + { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, + { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, + { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, + { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, + { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, + { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, + { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, + { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, + { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, + { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, + { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, + { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, + { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, + { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, + { url = "https://files.pythonhosted.org/packages/9b/01/0ebaec9003f5d619a7475165961f8e3083cf8644d704b60395df3601632d/propcache-0.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3d233076ccf9e450c8b3bc6720af226b898ef5d051a2d145f7d765e6e9f9bcff", size = 80277, upload-time = "2025-10-08T19:48:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/34/58/04af97ac586b4ef6b9026c3fd36ee7798b737a832f5d3440a4280dcebd3a/propcache-0.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:357f5bb5c377a82e105e44bd3d52ba22b616f7b9773714bff93573988ef0a5fb", size = 45865, upload-time = "2025-10-08T19:48:37.859Z" }, + { url = "https://files.pythonhosted.org/packages/7c/19/b65d98ae21384518b291d9939e24a8aeac4fdb5101b732576f8f7540e834/propcache-0.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cbc3b6dfc728105b2a57c06791eb07a94229202ea75c59db644d7d496b698cac", size = 47636, upload-time = "2025-10-08T19:48:39.038Z" }, + { url = "https://files.pythonhosted.org/packages/b3/0f/317048c6d91c356c7154dca5af019e6effeb7ee15fa6a6db327cc19e12b4/propcache-0.4.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:182b51b421f0501952d938dc0b0eb45246a5b5153c50d42b495ad5fb7517c888", size = 201126, upload-time = "2025-10-08T19:48:40.774Z" }, + { url = "https://files.pythonhosted.org/packages/71/69/0b2a7a5a6ee83292b4b997dbd80549d8ce7d40b6397c1646c0d9495f5a85/propcache-0.4.1-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4b536b39c5199b96fc6245eb5fb796c497381d3942f169e44e8e392b29c9ebcc", size = 209837, upload-time = "2025-10-08T19:48:42.167Z" }, + { url = "https://files.pythonhosted.org/packages/a5/92/c699ac495a6698df6e497fc2de27af4b6ace10d8e76528357ce153722e45/propcache-0.4.1-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:db65d2af507bbfbdcedb254a11149f894169d90488dd3e7190f7cdcb2d6cd57a", size = 215578, upload-time = "2025-10-08T19:48:43.56Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ee/14de81c5eb02c0ee4f500b4e39c4e1bd0677c06e72379e6ab18923c773fc/propcache-0.4.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd2dbc472da1f772a4dae4fa24be938a6c544671a912e30529984dd80400cd88", size = 197187, upload-time = "2025-10-08T19:48:45.309Z" }, + { url = "https://files.pythonhosted.org/packages/1d/94/48dce9aaa6d8dd5a0859bad75158ec522546d4ac23f8e2f05fac469477dd/propcache-0.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:daede9cd44e0f8bdd9e6cc9a607fc81feb80fae7a5fc6cecaff0e0bb32e42d00", size = 193478, upload-time = "2025-10-08T19:48:47.743Z" }, + { url = "https://files.pythonhosted.org/packages/60/b5/0516b563e801e1ace212afde869a0596a0d7115eec0b12d296d75633fb29/propcache-0.4.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:71b749281b816793678ae7f3d0d84bd36e694953822eaad408d682efc5ca18e0", size = 190650, upload-time = "2025-10-08T19:48:49.373Z" }, + { url = "https://files.pythonhosted.org/packages/24/89/e0f7d4a5978cd56f8cd67735f74052f257dc471ec901694e430f0d1572fe/propcache-0.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:0002004213ee1f36cfb3f9a42b5066100c44276b9b72b4e1504cddd3d692e86e", size = 200251, upload-time = "2025-10-08T19:48:51.4Z" }, + { url = "https://files.pythonhosted.org/packages/06/7d/a1fac863d473876ed4406c914f2e14aa82d2f10dd207c9e16fc383cc5a24/propcache-0.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:fe49d0a85038f36ba9e3ffafa1103e61170b28e95b16622e11be0a0ea07c6781", size = 200919, upload-time = "2025-10-08T19:48:53.227Z" }, + { url = "https://files.pythonhosted.org/packages/c3/4e/f86a256ff24944cf5743e4e6c6994e3526f6acfcfb55e21694c2424f758c/propcache-0.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:99d43339c83aaf4d32bda60928231848eee470c6bda8d02599cc4cebe872d183", size = 193211, upload-time = "2025-10-08T19:48:55.027Z" }, + { url = "https://files.pythonhosted.org/packages/6e/3f/3fbad5f4356b068f1b047d300a6ff2c66614d7030f078cd50be3fec04228/propcache-0.4.1-cp39-cp39-win32.whl", hash = "sha256:a129e76735bc792794d5177069691c3217898b9f5cee2b2661471e52ffe13f19", size = 38314, upload-time = "2025-10-08T19:48:56.792Z" }, + { url = "https://files.pythonhosted.org/packages/a4/45/d78d136c3a3d215677abb886785aae744da2c3005bcb99e58640c56529b1/propcache-0.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:948dab269721ae9a87fd16c514a0a2c2a1bdb23a9a61b969b0f9d9ee2968546f", size = 41912, upload-time = "2025-10-08T19:48:57.995Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2a/b0632941f25139f4e58450b307242951f7c2717a5704977c6d5323a800af/propcache-0.4.1-cp39-cp39-win_arm64.whl", hash = "sha256:5fd37c406dd6dc85aa743e214cef35dc54bbdd1419baac4f6ae5e5b1a2976938", size = 38450, upload-time = "2025-10-08T19:48:59.349Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/56/030b7b4719d53085722893e0009dffb9236aa10bca1b12121bdc5626ef16/propcache-0.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a81be28596d6559f6131ef33e10200de6e17643b3c74ce03f9eb103be6ae8b", size = 93417, upload-time = "2026-05-08T20:59:15.597Z" }, + { url = "https://files.pythonhosted.org/packages/1a/55/1140a8e067b8ec093a18a4ae7bb0045d9db65da38a08618ddc5e2f1994aa/propcache-0.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29cbaac5ea0212663e6845e04b5e188d5a6ae6dd919810ac835bf1d3b42c3f4c", size = 53847, upload-time = "2026-05-08T20:59:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/20/42/0e7443c90310498561addf346e7d57fe3c6ba1914e1ba938b5464c7bbfd2/propcache-0.5.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6bf3be92233808fcd338eba0fb4d0b59ec5772af4f4ecfcec450d1bfc0f8b5eb", size = 53512, upload-time = "2026-05-08T20:59:18.64Z" }, + { url = "https://files.pythonhosted.org/packages/b7/db/cf51a71bab2009517d1a7f0ee07657e3bd446c4d69f67e6966cf17bcf956/propcache-0.5.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f8ea531c794b9d6274acd4e8d2c2ebcac590a4361d27482edd3010b79f1325e", size = 58068, upload-time = "2026-05-08T20:59:20.683Z" }, + { url = "https://files.pythonhosted.org/packages/b7/43/39b6bdee9699fa1e1641c519feeb64a67e2a9f93bb465c70776b37a7333f/propcache-0.5.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:decfca4c79dd53ebab484b00cc4b6717d8c369f86e74aa4ca395a64ac651495e", size = 61020, upload-time = "2026-05-08T20:59:22.112Z" }, + { url = "https://files.pythonhosted.org/packages/26/0b/843726fbb0a29a8c5684fdb25971823638399f31e52e9d1f06a02dc9aa6b/propcache-0.5.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4621064bbf28fa77ff64dd5d94367c04684c67d3a5bf1dff25f0cd0d98a38f3b", size = 62732, upload-time = "2026-05-08T20:59:23.805Z" }, + { url = "https://files.pythonhosted.org/packages/39/6e/899fed76dc1942b8a64193a4f059d7f1a2c7ef65085e8a9366ed8ec0d199/propcache-0.5.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b96db7141a592cbc968daf1feea83a118e6ab378af4abbc72b248c895414c22d", size = 60140, upload-time = "2026-05-08T20:59:25.389Z" }, + { url = "https://files.pythonhosted.org/packages/ab/09/3da4be9b5b879219ad234aa535b3dd4a080ed1ad48d3a73ca07a9e798f22/propcache-0.5.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ca071adabaab6e9219924bbe00af821f1ee7de113a9eca1cdc292de3d120f4d", size = 60400, upload-time = "2026-05-08T20:59:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/60/2f/09b72b874a9aa0044faf52a69807a6ed618e267ceaa9ec4a63195fa5b504/propcache-0.5.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e4294d04a94dcab1b3bccd8b66d962dcad411a1d19414b2a41d1445f1de32ad0", size = 58155, upload-time = "2026-05-08T20:59:28.48Z" }, + { url = "https://files.pythonhosted.org/packages/8a/37/97489848c54c95578045473954f10956d619ce6a09e7ac137b71cdcb698b/propcache-0.5.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a0e399a2eccb91ed18721f86aa85757727400b6865c89e88934781deb9c8498b", size = 57037, upload-time = "2026-05-08T20:59:30.146Z" }, + { url = "https://files.pythonhosted.org/packages/22/db/6c695285ccfc49012743ee9c98212b8c5dd0aed7b63cfd816d4a0f7a1601/propcache-0.5.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:823581fd5cb08b12a48bfa11fe962a7916766b6170c17b028fbdf762b85eb9bf", size = 61103, upload-time = "2026-05-08T20:59:31.626Z" }, + { url = "https://files.pythonhosted.org/packages/98/a9/1e500401ca593b0bdb6bf75a70bc2d723835fd53360edff6af70692c7546/propcache-0.5.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:949c91d1a990cf3b2e8188dfcfb25005e0b834a06c63fa4ef9f360878ce21ecf", size = 60394, upload-time = "2026-05-08T20:59:32.829Z" }, + { url = "https://files.pythonhosted.org/packages/1f/87/f638b6e375eae0f30a1a2325d8b34fd85fdc785bb9960cf805f3bf1ec69a/propcache-0.5.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:cc1177027eda740fdb152706bd215a3f124e3eea15afc39f2cb9fe351b50619e", size = 63084, upload-time = "2026-05-08T20:59:35.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/18/884573f5d97b6d9eba68de759a82c901b7e39d7904d30f7b8d58d42d2a12/propcache-0.5.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b05d643f944a8c3c4bd86d65ffd87bf3264b617f87791940302bc474d2ff5274", size = 60999, upload-time = "2026-05-08T20:59:38.481Z" }, + { url = "https://files.pythonhosted.org/packages/8f/1a/c3915eb059ceec9e758a56e4cfd955292bc0f201be2176a46b76d94b303a/propcache-0.5.2-cp310-cp310-win32.whl", hash = "sha256:8114f28879e0904748e831c3a7774261bd9e75f49be089f389a76f959dcd13fe", size = 39036, upload-time = "2026-05-08T20:59:40.323Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/1dfd5607501a602d19c1c449d2d193b7d1c611f9246b4059026a1189a80e/propcache-0.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:5fcb98e7598b1ee0addab320d90f65b530297a867dbfe9de52ea838077e16e3d", size = 42190, upload-time = "2026-05-08T20:59:42.232Z" }, + { url = "https://files.pythonhosted.org/packages/57/93/f71588ad08b3e6f4b555b5ef215808a3c02b042d0151ad82fa6f15be677a/propcache-0.5.2-cp310-cp310-win_arm64.whl", hash = "sha256:04dc2390d9edbbaef7461f33322555976ffddf0b650a038649d026358714e6c5", size = 38545, upload-time = "2026-05-08T20:59:44.087Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", size = 90744, upload-time = "2026-05-08T20:59:45.799Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", size = 52033, upload-time = "2026-05-08T20:59:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", size = 52754, upload-time = "2026-05-08T20:59:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fe/b3551b41bbc2f5b5bb088fc6920567cd43101253e68fbaa261339eb96fe1/propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511", size = 57573, upload-time = "2026-05-08T20:59:50.778Z" }, + { url = "https://files.pythonhosted.org/packages/83/27/ab851ebd1b7172e3e161f5f8d39e315d54a91bea246f01f4d872d3376aef/propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660", size = 60645, upload-time = "2026-05-08T20:59:52.227Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/466b3d18022e9897cbda9c735c493c5bd747d7a4c6f5ea1480b4cec434b6/propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66", size = 61563, upload-time = "2026-05-08T20:59:53.866Z" }, + { url = "https://files.pythonhosted.org/packages/27/1b/16ab7f2cf2041da2f60d156ba64c2484eadf9168075b4ff43c3ef60045af/propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b", size = 58888, upload-time = "2026-05-08T20:59:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/0a/67/bb777ffd907633563bf35fd859c4ce97b0512c32f4633cf5d1eb7c33512b/propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67", size = 59253, upload-time = "2026-05-08T20:59:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/b9/42/64f8d90b73fd9cdc1499b48057ff6d9cd2a98a25734c9bb62ecf07e87061/propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f", size = 57558, upload-time = "2026-05-08T20:59:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/02/dba5bc03c9041f2092ea55a449caf5dfe68352c6654511b29ba0654ddb69/propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c", size = 55007, upload-time = "2026-05-08T20:59:59.837Z" }, + { url = "https://files.pythonhosted.org/packages/14/c0/43f649c7aa2a77a3b100d84e9dea3a483120ecb608bfe36ce49eaff517fe/propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0", size = 60355, upload-time = "2026-05-08T21:00:01.144Z" }, + { url = "https://files.pythonhosted.org/packages/83/c0/435dafd27f1cb4a495381dae60e25883ccfe4020bb72818e8184c1678092/propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6", size = 59057, upload-time = "2026-05-08T21:00:02.401Z" }, + { url = "https://files.pythonhosted.org/packages/53/ae/6e292df9135d659944e96cb3389258e4a663e5b2b5f6c217ef0ddc8d2f73/propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27", size = 61938, upload-time = "2026-05-08T21:00:03.638Z" }, + { url = "https://files.pythonhosted.org/packages/0b/42/314ebc50d8159055411fd6b0bda322ff510e4b1f7d2e4927940ad0f6af20/propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f", size = 59731, upload-time = "2026-05-08T21:00:04.881Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9b/2da6dee38871c3c8772fabc2758325a5c9077d6d18c597737dc04dd884cd/propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0", size = 38966, upload-time = "2026-05-08T21:00:06.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/4e/f17363fb58c0afe05b067361cb6d86ed2d29de6506779a27547c4d183075/propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82", size = 42135, upload-time = "2026-05-08T21:00:08.088Z" }, + { url = "https://files.pythonhosted.org/packages/c6/eb/6af6685077d22e8b33358d3c548e3282706a0b3cd85044ffba4e5dd08e3b/propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab", size = 38381, upload-time = "2026-05-08T21:00:09.692Z" }, + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, + { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, + { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "pure-sasl" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/b7/a0d688f86c869073cc28c0640899394a1cf68a6d87ee78a09565e9037da6/pure-sasl-0.6.2.tar.gz", hash = "sha256:53c1355f5da95e2b85b2cc9a6af435518edc20c81193faa0eea65fdc835138f4", size = 11617, upload-time = "2019-10-14T21:43:57.13Z" } + +[[package]] +name = "pycparser" +version = "2.23" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9.12' and python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.9.12'", + "python_full_version <= '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyopenssl" +version = "26.2.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version <= '3.9'", +] +dependencies = [ + { name = "cryptography", version = "47.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.9'" }, + { name = "typing-extensions", marker = "python_full_version <= '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/51/27a5ad5f939d08f690a326ef9582cda7140555180db71695f6fb747d6a36/pyopenssl-26.2.0.tar.gz", hash = "sha256:8c6fcecd1183a7fc897548dfe388b0cdb7f37e018200d8409cf33959dbe35387", size = 182195, upload-time = "2026-05-04T23:06:09.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/b8/a0e2790ae249d6f38c9f66de7a211621a7ab2650217bcd04e1262f578a56/pyopenssl-26.2.0-py3-none-any.whl", hash = "sha256:4f9d971bc5298b8bc1fab282803da04bf000c755d4ad9d99b52de2569ca19a70", size = 55823, upload-time = "2026-05-04T23:06:08.395Z" }, +] + +[[package]] +name = "pyopenssl" +version = "26.4.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", + "python_full_version >= '3.9.12' and python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.9.12'", +] +dependencies = [ + { name = "cryptography", version = "50.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.9'" }, + { name = "typing-extensions", marker = "python_full_version > '3.9' and python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/e8/7325d258199b159eb2c03fe32107533e2832e70e63f4fb88a6aa00023201/pyopenssl-26.4.0.tar.gz", hash = "sha256:28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7", size = 182046, upload-time = "2026-08-01T19:50:50.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/ad/2cf6d3fa2fae5c79e1ed9960c0d42badd0f94d81dd12b50604cdc839e648/pyopenssl-26.4.0-py3-none-any.whl", hash = "sha256:f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c", size = 56026, upload-time = "2026-08-01T19:50:48.94Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-snappy" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cramjam" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/66/9185fbb6605ba92716d9f77fbb13c97eb671cd13c3ad56bd154016fbf08b/python_snappy-0.7.3.tar.gz", hash = "sha256:40216c1badfb2d38ac781ecb162a1d0ec40f8ee9747e610bcfefdfa79486cee3", size = 9337, upload-time = "2024-08-29T13:16:05.705Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/c1/0ee413ddd639aebf22c85d6db39f136ccc10e6a4b4dd275a92b5c839de8d/python_snappy-0.7.3-py3-none-any.whl", hash = "sha256:074c0636cfcd97e7251330f428064050ac81a52c62ed884fc2ddebbb60ed7f50", size = 9155, upload-time = "2024-08-29T13:16:04.773Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/67fc8e68a75f738c9200422bf65693fb79a4cd0dc5b23310e5202e978090/pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", size = 184450, upload-time = "2025-09-25T21:33:00.618Z" }, + { url = "https://files.pythonhosted.org/packages/ae/92/861f152ce87c452b11b9d0977952259aa7df792d71c1053365cc7b09cc08/pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", size = 174319, upload-time = "2025-09-25T21:33:02.086Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cd/f0cfc8c74f8a030017a2b9c771b7f47e5dd702c3e28e5b2071374bda2948/pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", size = 737631, upload-time = "2025-09-25T21:33:03.25Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b2/18f2bd28cd2055a79a46c9b0895c0b3d987ce40ee471cecf58a1a0199805/pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", size = 836795, upload-time = "2025-09-25T21:33:05.014Z" }, + { url = "https://files.pythonhosted.org/packages/73/b9/793686b2d54b531203c160ef12bec60228a0109c79bae6c1277961026770/pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", size = 750767, upload-time = "2025-09-25T21:33:06.398Z" }, + { url = "https://files.pythonhosted.org/packages/a9/86/a137b39a611def2ed78b0e66ce2fe13ee701a07c07aebe55c340ed2a050e/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", size = 727982, upload-time = "2025-09-25T21:33:08.708Z" }, + { url = "https://files.pythonhosted.org/packages/dd/62/71c27c94f457cf4418ef8ccc71735324c549f7e3ea9d34aba50874563561/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", size = 755677, upload-time = "2025-09-25T21:33:09.876Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/6f5e0d58bd924fb0d06c3a6bad00effbdae2de5adb5cda5648006ffbd8d3/pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", size = 142592, upload-time = "2025-09-25T21:33:10.983Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777, upload-time = "2025-09-25T21:33:15.55Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9.12' and python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.9.12'", + "python_full_version <= '3.9'", +] +dependencies = [ + { name = "certifi", marker = "python_full_version < '3.10'" }, + { name = "charset-normalizer", marker = "python_full_version < '3.10'" }, + { name = "idna", marker = "python_full_version < '3.10'" }, + { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "certifi", marker = "python_full_version >= '3.10'" }, + { name = "charset-normalizer", marker = "python_full_version >= '3.10'" }, + { name = "idna", marker = "python_full_version >= '3.10'" }, + { name = "urllib3", version = "2.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "ruamel-yaml" +version = "0.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/3b/ebda527b56beb90cb7652cb1c7e4f91f48649fbcd8d2eb2fb6e77cd3329b/ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993", size = 142709, upload-time = "2026-01-02T16:50:31.84Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102, upload-time = "2026-01-02T16:50:29.201Z" }, +] + +[[package]] +name = "s3transfer" +version = "0.16.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9.12' and python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.9.12'", + "python_full_version <= '3.9'", +] +dependencies = [ + { name = "botocore", version = "1.42.97", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/29/af14f4ef3c11a50435308660e2cc68761c9a7742475e0585cd4396b91777/s3transfer-0.16.1.tar.gz", hash = "sha256:8e424355754b9ccb32467bdc568edf55be82692ef2002d934b1311dbb3b9e524", size = 154801, upload-time = "2026-04-22T20:36:06.475Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/19/90d7d4ed51932c022d53f1d02d564b62d10e272692a1f9b76425c1ad2a02/s3transfer-0.16.1-py3-none-any.whl", hash = "sha256:61bcd00ccb83b21a0fe7e91a553fff9729d46c83b4e0106e7c314a733891f7c2", size = 86825, upload-time = "2026-04-22T20:36:04.992Z" }, +] + +[[package]] +name = "s3transfer" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "botocore", version = "1.43.64", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/43/35e4d8aa320bffe8287fe8f65f578fa2d2db0a64212f0e710dce58267854/s3transfer-0.19.2.tar.gz", hash = "sha256:ba0309fd86be3c27dbf78cdd813c13c5e1df16e5874b99d2535ebbdfb9892993", size = 165592, upload-time = "2026-07-22T19:30:44.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl", hash = "sha256:d8168eccca828cbb2cd573675333f3bddd254313a9c42494b84c76b539e8ba25", size = 90216, upload-time = "2026-07-22T19:30:43.251Z" }, +] + +[[package]] +name = "scylla-driver" +source = { editable = "." } +dependencies = [ + { name = "geomet" }, + { name = "pyyaml" }, +] + +[package.optional-dependencies] +auth-kerberos = [ + { name = "kerberos", marker = "sys_platform != 'win32'" }, + { name = "winkerberos", version = "0.12.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "winkerberos", version = "0.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, +] +cle = [ + { name = "cryptography", version = "47.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.9'" }, + { name = "cryptography", version = "50.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.9'" }, +] +compress-lz4 = [ + { name = "lz4" }, +] +compress-snappy = [ + { name = "python-snappy" }, +] +graph = [ + { name = "gremlinpython", version = "3.7.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "gremlinpython", version = "3.8.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] + +[package.dev-dependencies] +dev = [ + { name = "ccm" }, + { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version < '3.10'" }, + { name = "coverage", version = "7.15.3", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version >= '3.10'" }, + { name = "cython" }, + { name = "eventlet", version = "0.40.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "eventlet", version = "0.41.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "futurist", version = "3.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "futurist", version = "3.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "futurist", version = "3.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "gevent" }, + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "objgraph" }, + { name = "packaging" }, + { name = "pure-sasl" }, + { name = "pytest" }, + { name = "pyyaml" }, + { name = "setuptools", version = "82.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "setuptools", version = "83.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "twisted", version = "25.5.0", source = { registry = "https://pypi.org/simple" }, extra = ["tls"], marker = "python_full_version < '3.9.12'" }, + { name = "twisted", version = "26.4.0", source = { registry = "https://pypi.org/simple" }, extra = ["tls"], marker = "python_full_version >= '3.9.12'" }, +] + +[package.metadata] +requires-dist = [ + { name = "cryptography", marker = "extra == 'cle'", specifier = ">=42.0" }, + { name = "geomet", specifier = ">=1.1" }, + { name = "gremlinpython", marker = "extra == 'graph'", specifier = ">=3.7.4,<4" }, + { name = "kerberos", marker = "sys_platform != 'win32' and extra == 'auth-kerberos'" }, + { name = "lz4", marker = "extra == 'compress-lz4'" }, + { name = "python-snappy", marker = "extra == 'compress-snappy'" }, + { name = "pyyaml", specifier = ">5.0" }, + { name = "winkerberos", marker = "sys_platform == 'win32' and extra == 'auth-kerberos'" }, +] +provides-extras = ["graph", "cle", "compress-lz4", "compress-snappy", "auth-kerberos"] + +[package.metadata.requires-dev] +dev = [ + { name = "ccm", git = "https://github.com/scylladb/scylla-ccm.git?rev=master" }, + { name = "coverage", extras = ["toml"], specifier = ">=7.6" }, + { name = "cython", specifier = ">=3.2" }, + { name = "eventlet", specifier = ">=0.33.3" }, + { name = "futurist" }, + { name = "gevent" }, + { name = "numpy" }, + { name = "objgraph" }, + { name = "packaging", specifier = ">=25.0" }, + { name = "pure-sasl" }, + { name = "pytest", specifier = "~=8.0" }, + { name = "pyyaml" }, + { name = "setuptools" }, + { name = "twisted", extras = ["tls"] }, +] + +[[package]] +name = "service-identity" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cryptography", version = "47.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.9'" }, + { name = "cryptography", version = "50.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/87/ad52e2c582c0f0e7f0a1b86950494c38d67422dc0f5ed9044a5fb9569a49/service_identity-26.1.0.tar.gz", hash = "sha256:6358c52882c96e66ac4a55eb3a72c7dd4a70763f8cc6fa4e70abde2656f4bf3b", size = 42898, upload-time = "2026-05-30T12:04:55.184Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/eb/2433e1af4ff903499144de4846569fb3300b816179ae99a03c2f011b666a/service_identity-26.1.0-py3-none-any.whl", hash = "sha256:68c32dadbb69135fb951077677e07cd7f6031020f3a8c8f47a28cda8a0742118", size = 11370, upload-time = "2026-05-30T12:04:53.911Z" }, +] + +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9.12' and python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.9.12'", + "python_full_version <= '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + +[[package]] +name = "setuptools" +version = "83.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "tqdm" +version = "4.70.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" }, +] + +[[package]] +name = "twisted" +version = "25.5.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version > '3.9' and python_full_version < '3.9.12'", + "python_full_version <= '3.9'", +] +dependencies = [ + { name = "attrs", marker = "python_full_version < '3.9.12'" }, + { name = "automat", marker = "python_full_version < '3.9.12'" }, + { name = "constantly", marker = "python_full_version < '3.9.12'" }, + { name = "hyperlink", marker = "python_full_version < '3.9.12'" }, + { name = "incremental", marker = "python_full_version < '3.9.12'" }, + { name = "typing-extensions", marker = "python_full_version < '3.9.12'" }, + { name = "zope-interface", version = "8.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/0f/82716ed849bf7ea4984c21385597c949944f0f9b428b5710f79d0afc084d/twisted-25.5.0.tar.gz", hash = "sha256:1deb272358cb6be1e3e8fc6f9c8b36f78eb0fa7c2233d2dbe11ec6fee04ea316", size = 3545725, upload-time = "2025-06-07T09:52:24.858Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/66/ab7efd8941f0bc7b2bd555b0f0471bff77df4c88e0cc31120c82737fec77/twisted-25.5.0-py3-none-any.whl", hash = "sha256:8559f654d01a54a8c3efe66d533d43f383531ebf8d81d9f9ab4769d91ca15df7", size = 3204767, upload-time = "2025-06-07T09:52:21.428Z" }, +] + +[package.optional-dependencies] +tls = [ + { name = "idna", marker = "python_full_version < '3.9.12'" }, + { name = "pyopenssl", version = "26.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.9'" }, + { name = "pyopenssl", version = "26.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.9' and python_full_version < '3.9.12'" }, + { name = "service-identity", marker = "python_full_version < '3.9.12'" }, +] + +[[package]] +name = "twisted" +version = "26.4.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", + "python_full_version >= '3.9.12' and python_full_version < '3.10'", +] +dependencies = [ + { name = "attrs", marker = "python_full_version >= '3.9.12'" }, + { name = "automat", marker = "python_full_version >= '3.9.12'" }, + { name = "constantly", marker = "python_full_version >= '3.9.12'" }, + { name = "hyperlink", marker = "python_full_version >= '3.9.12'" }, + { name = "incremental", marker = "python_full_version >= '3.9.12'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.9.12'" }, + { name = "zope-interface", version = "8.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9.12' and python_full_version < '3.10'" }, + { name = "zope-interface", version = "8.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/97/6e9beb1e78247ae6dc34114f27d538cf2cb183c4afcd3609dfdf2b0439c8/twisted-26.4.0.tar.gz", hash = "sha256:dbfd0fe1ee409d0243fdd7a6a6ff14f4948cec1fd78e0376291f805e1501fae9", size = 3575095, upload-time = "2026-05-11T11:24:51.861Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/57/bcf4e2370dd218c9aa68a9140a65d86729c73f1d529f7e94786c2766fc72/twisted-26.4.0-py3-none-any.whl", hash = "sha256:dc25ea0ebf6511c24f03232ee9f4afa54b291c5d897990e3a39cc4d14a1ef4c0", size = 3230362, upload-time = "2026-05-11T11:24:49.5Z" }, +] + +[package.optional-dependencies] +tls = [ + { name = "idna", marker = "python_full_version >= '3.9.12'" }, + { name = "pyopenssl", version = "26.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9.12'" }, + { name = "service-identity", marker = "python_full_version >= '3.9.12'" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "urllib3" +version = "1.26.20" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9.12' and python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.9.12'", + "python_full_version <= '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/e8/6ff5e6bc22095cfc59b6ea711b687e2b7ed4bdb373f7eeec370a97d7392f/urllib3-1.26.20.tar.gz", hash = "sha256:40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32", size = 307380, upload-time = "2024-08-29T15:43:11.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/cf/8435d5a7159e2a9c83a95896ed596f68cf798005fe107cc655b5c5c14704/urllib3-1.26.20-py2.py3-none-any.whl", hash = "sha256:0ed14ccfbf1c30a9072c7ca157e4319b70d65f623e91e7b32fadb2853431016e", size = 144225, upload-time = "2024-08-29T15:43:08.921Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "winkerberos" +version = "0.12.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9.12' and python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.9.12'", + "python_full_version <= '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/75/86d470935167eb1c40d53498993e14cc021d9611a539d61c9b4202c291ab/winkerberos-0.12.2.tar.gz", hash = "sha256:ff91daed04727a0362892802ee093d8da11f08536393526bdf3bc64e04079faa", size = 35672, upload-time = "2025-04-02T14:41:48.274Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/ac/c6ce495af45371ffd85a6a3d24c2ced679b8dbcf3b8c6beca093706b1620/winkerberos-0.12.2-cp310-cp310-win32.whl", hash = "sha256:f8b751bd5a28e6a9146f154bed395c30ce4f245448addc763f98cb8843879027", size = 25331, upload-time = "2025-04-02T14:41:36.398Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/ad32174c3ed4710cd2ad8f20171f5061cb13603f091d714d5aa6b30d51f0/winkerberos-0.12.2-cp310-cp310-win_amd64.whl", hash = "sha256:4be3b0de548b80f52a6544dff9d571da6cdfde590176a01477358b3808b12dfa", size = 27670, upload-time = "2025-04-02T14:41:37.68Z" }, + { url = "https://files.pythonhosted.org/packages/91/12/23b29d359dee9f7a8243cb0040ea1834acd1af8cbc38cfe1c7ca82ab4ec0/winkerberos-0.12.2-cp311-cp311-win32.whl", hash = "sha256:ff2b2ec9b9246bbc05f0d4e6fe5f3f3563237357b9b35eaa58ec1a9ddf349ab8", size = 25332, upload-time = "2025-04-02T14:41:38.671Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/2bfa1dcdb4a47b7f989a9e758c892bd7393a156b0e1f0df63eca8304e892/winkerberos-0.12.2-cp311-cp311-win_amd64.whl", hash = "sha256:e6ac2b2cc329a68502821905f6ffe48e109d54a46aba7414ea231a30c75bb2d9", size = 27671, upload-time = "2025-04-02T14:41:40.104Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/26c5b1435654596c07b314653183ffe42b64ea07041c328f0fd4c68fe9f9/winkerberos-0.12.2-cp312-cp312-win32.whl", hash = "sha256:46dac1300e20738cbaf6c17c2e4832062ed7faee346c7a96f0e57f8bbe279c25", size = 25396, upload-time = "2025-04-02T14:41:41.6Z" }, + { url = "https://files.pythonhosted.org/packages/64/b1/6c4a1e4e50553798eb44dbb0d71ba6af48e2a62a0eb01bd0d4e2b41914e3/winkerberos-0.12.2-cp312-cp312-win_amd64.whl", hash = "sha256:2c5c7a70c0d4a43546b20d5654e7e7e5e5e96f42084a7f293864f7ad0fb1e953", size = 27710, upload-time = "2025-04-02T14:41:42.656Z" }, + { url = "https://files.pythonhosted.org/packages/5f/91/cff6750c7c3b2a9f35e12cd7c4df901251fc3be985edef707a3458c43e9a/winkerberos-0.12.2-cp313-cp313-win32.whl", hash = "sha256:482a72500b7822cc8f941d0c6eed668a24c030ac145c97732e175b51441bebbf", size = 25391, upload-time = "2025-04-02T14:41:43.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/98/defb037ad127c4006c4e992dd55ce0df92059626d3df5f5f4c5fc8502c26/winkerberos-0.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:efd65ba54534512070916cb9c91ef9798a0f9fb0b04e12732c9631e71553fd69", size = 27704, upload-time = "2025-04-02T14:41:45.203Z" }, + { url = "https://files.pythonhosted.org/packages/be/17/b16e72e0b896cdf05666994cbc402a66f5911d56ea28d4e858714328b698/winkerberos-0.12.2-cp39-cp39-win32.whl", hash = "sha256:0c80eed53472a38d7f1dd015e27d93705b22a2acd2557bad13d8b5d688037b29", size = 25326, upload-time = "2025-04-02T14:41:46.216Z" }, + { url = "https://files.pythonhosted.org/packages/65/04/ae42e839e8d836fde613f94f30395953292a7b9be388247237196d1e5caa/winkerberos-0.12.2-cp39-cp39-win_amd64.whl", hash = "sha256:4b908aab5ab42e98bee44eca67dfebe4733d210bccf021e42b669bf4af2005a4", size = 27663, upload-time = "2025-04-02T14:41:47.294Z" }, +] + +[[package]] +name = "winkerberos" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/6c/455f043bc28694a278125d1fc2ab7cbf0ce0953c97bbe1021f08fd19c7b8/winkerberos-0.13.0.tar.gz", hash = "sha256:f3fbb67346fe8ed697e125724b0699d5c2a15b9a5f9151d25a1be88df8dac427", size = 35677, upload-time = "2025-12-03T14:17:29.882Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/45/7199a756e3b25757cbf5986c8af040647aba24b039493eddff7950007f31/winkerberos-0.13.0-cp310-cp310-win32.whl", hash = "sha256:e6df7ab4c4e39e3e1d539b32ea20df84dc7ac32391391bf415c2a8051082051d", size = 25637, upload-time = "2025-12-03T14:17:15.236Z" }, + { url = "https://files.pythonhosted.org/packages/de/90/9b1e787831496683c494f50e05fe08a0579e51c4d3b8bbc90d7fadbf8858/winkerberos-0.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:a1293325d69bfd75aefecde45ee1e52a0adfc29f2e19650eea9a87fddaa20b02", size = 27925, upload-time = "2025-12-03T14:17:16.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/09/05c4d2fb93f5478fd1b6146c4fa3fbb80839576a34062e5677f2dec3a430/winkerberos-0.13.0-cp311-cp311-win32.whl", hash = "sha256:a23c83854650416545000c4630e94b16fa14c7b400bd5f08a79718e04eff9135", size = 25642, upload-time = "2025-12-03T14:17:17.42Z" }, + { url = "https://files.pythonhosted.org/packages/6e/5b/bafa1cfb9f047be139ffae330f6eafa0487f8bf82164ead756e0bc2bc047/winkerberos-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:6bc03e66a737bfd11964e6cdc5f03a8cd0baed798f991b1467075c65980c4157", size = 27931, upload-time = "2025-12-03T14:17:18.708Z" }, + { url = "https://files.pythonhosted.org/packages/3a/fa/02de79d7dbec9122a6778678ed432ebffb228c48b16cfba3007c45a6e8fd/winkerberos-0.13.0-cp312-cp312-win32.whl", hash = "sha256:3454b8bb9c11091e4775a8bd692dfbe45f2eab12f3a4837b820c2505088dfdd2", size = 25675, upload-time = "2025-12-03T14:17:20.052Z" }, + { url = "https://files.pythonhosted.org/packages/52/c2/ff9074cf423d82bdfb48ac89e64f360533ba4e2079e8485be8377a8c54fe/winkerberos-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:59f01879c62adcda5af857fd78d2b2dfdfd99cf6179b92d38e2f2bd12db75bf7", size = 27946, upload-time = "2025-12-03T14:17:21.22Z" }, + { url = "https://files.pythonhosted.org/packages/92/83/b1f52594cc2c3ce18c67a04aecb0cb4fb3f4769c268d194cc5f4863150fa/winkerberos-0.13.0-cp313-cp313-win32.whl", hash = "sha256:38fefdfc77a7f82c3cc9f83c7d1b6f242e6d3ea200bfde9b640f7dfe9fdf9bda", size = 25671, upload-time = "2025-12-03T14:17:23.235Z" }, + { url = "https://files.pythonhosted.org/packages/9c/26/b17649b0707e4d8cd9d0d4ceadcef06eff2fc76fcb444cb187763158ae63/winkerberos-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:c45e84a35a3b87b88d0e6d7b55d40712dc021f80af3cb9e81091651e6a73510d", size = 27941, upload-time = "2025-12-03T14:17:24.627Z" }, + { url = "https://files.pythonhosted.org/packages/80/d9/d12d310fdf9ace70f7469ecfd9f112dc39cb7e1f77348228c06a6bd72c57/winkerberos-0.13.0-cp314-cp314-win32.whl", hash = "sha256:46cc29fa95744076a0dd2a167158574826509a5e4aa052b81a2b535aab4af14a", size = 26185, upload-time = "2025-12-03T14:17:25.63Z" }, + { url = "https://files.pythonhosted.org/packages/97/7c/5a418e8d292e3fea1012ccf029b38fae430542fab1beaf6fc60cf138cc08/winkerberos-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:5d5add54d10e31671f7c28c90ccafe98b45cec6d7519949ba30add51e34aee9a", size = 28476, upload-time = "2025-12-03T14:17:26.629Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/cd8186479046b7a749cee8d4d9fd50e3ce3330d8ea611efe4b8b741f0c3b/winkerberos-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:5bc5e40a816d94d4a5abd665fe62088c1ee91ee9a1f5d787032a63004842fedf", size = 26500, upload-time = "2025-12-03T14:17:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a6/cc5f24b3f1a46a826b7e30ef56fdc1fe22315fef96de8e22afbdd5d98e7a/winkerberos-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:441884c0bda4bee0125fdbd7fee6a232dab58b4a64be8950eb17a8a7404a5440", size = 28715, upload-time = "2025-12-03T14:17:28.813Z" }, +] + +[[package]] +name = "wrapt" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/b0/c1f5a970721f06b85c0cd5142e0ff8fe067708abd779b0c4f4be7d61d09f/wrapt-2.3.0.tar.gz", hash = "sha256:681a2d0eefd721998f90642762b8e75c2159ec531b20ad5e437245ea7b06a107", size = 131509, upload-time = "2026-07-28T06:06:14.895Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/31/5822ce37ca8820c2ed35a498c67c8b37960b9cee2ba437fd32849d0a234c/wrapt-2.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0bb2797048db0956348cb3058c33bc4184614f13231389cfbccc16a5d32780a7", size = 81191, upload-time = "2026-07-28T06:04:04.858Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5a/3c6117938be98754578ab83f5a40d7d0ea2cd2c487dc5cd6027ee7228229/wrapt-2.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ce9f398f868d2b3b27aa2ea4de79645ef9077aeeac8dfc2814b0d542c6a2b87f", size = 82255, upload-time = "2026-07-28T06:04:07.151Z" }, + { url = "https://files.pythonhosted.org/packages/a5/0f/94ae724c5087eb6054c0d63febd7094947dcf302fe058e2e0488102a872b/wrapt-2.3.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ad71df7a04dd3497e9302e81f4a7c91bd401ea0e15a9df9029527900f94bee43", size = 155228, upload-time = "2026-07-28T06:04:08.272Z" }, + { url = "https://files.pythonhosted.org/packages/6c/21/1f780bba935dcf697c0c59de9be3a559bbb8e31a53ca3f25422023738432/wrapt-2.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fc82c2ccc8e234c844f5303d9f2984b346dcdd53e94823ce8420d2c75b4b9023", size = 157073, upload-time = "2026-07-28T06:04:09.459Z" }, + { url = "https://files.pythonhosted.org/packages/73/31/6c7799d7b6431fcd7e1b83245fb45258a2d2c3a2187fbaecb83572a72d7a/wrapt-2.3.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6e19531ae33c508cea7d84a7edfda01fa86e51b8d1a93a77712c55e6e469152", size = 151594, upload-time = "2026-07-28T06:04:10.784Z" }, + { url = "https://files.pythonhosted.org/packages/ce/17/42d670dbfafd49076c6eb2b7d67633d7e1c968e39bfb11a135acb6fac67b/wrapt-2.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:df4ce31150bcd5d9f36f816aac3010ab4f4bf8672ac1d3b0ac7d539ec61c7c02", size = 156069, upload-time = "2026-07-28T06:04:12.316Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d6/c66b4ba4eda49257c84d5c2df26118280f09ca7905aee20d0064db778d13/wrapt-2.3.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e2e692bc0d63f881cf7006730a56bd4e0c2fab5dc318466942805d692b166276", size = 150930, upload-time = "2026-07-28T06:04:13.482Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f2/1a3b949c0322fb27396eafd1044328c1cb0400e0b32105d75a3cd03096e7/wrapt-2.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c8388ba7faf5dbf9ee106bb70d66f257629b1bd98091123e19e8a4553a319199", size = 154525, upload-time = "2026-07-28T06:04:14.698Z" }, + { url = "https://files.pythonhosted.org/packages/12/65/147563a3dfa6e830c857b93b530ebd8c0cd9d540e5914aec8f9b12880c02/wrapt-2.3.0-cp310-cp310-win32.whl", hash = "sha256:e045ff75d7d94900fc32896ed93c45ce2d2cac28c9dead582ff9a5a49d446e35", size = 77879, upload-time = "2026-07-28T06:04:16.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/eb/921405b4dc55d4f8be4c700ef120539fdd75d5fdb50d83bd257171ee18e0/wrapt-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:b4fc96b159af0a3e0faa72475a69d66292bea72a5bed1e1aca1bffbddc3cb2b0", size = 80733, upload-time = "2026-07-28T06:04:17.43Z" }, + { url = "https://files.pythonhosted.org/packages/b6/13/75947450c5bb57795fa86384721cd52c5c4deb0879022f309501a8a85d44/wrapt-2.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:1236fa25173ca964c97422470482e9011b9e3c7ed0d75798b40b3da3b0e0e760", size = 80199, upload-time = "2026-07-28T06:04:18.761Z" }, + { url = "https://files.pythonhosted.org/packages/00/b8/9182e4c618a847be0baccb68e4602b070d0fa22c782cf058f4bc66b32709/wrapt-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5ab559e1b2551d23d54db2a0001c6d73bad022a254639561c5f6c382a9d6c2fe", size = 81427, upload-time = "2026-07-28T06:04:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/84/ca/613cefd9c5977366b1587e61c0b428176d382e6d75b454084c5e58503042/wrapt-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bff9a671bc00709cab5a7f745c592b5671873449db0ee2a569af994f16b29a4d", size = 82360, upload-time = "2026-07-28T06:04:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/71/71/4cd2151a236f44a6e2dd4ed8011838d7ba0be3d656c8bafdfc65a2ed1917/wrapt-2.3.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fc648a335d7e01adb3640b25f02fd0ea05886cf04d0af7f4ee902bc7b5e466e8", size = 161700, upload-time = "2026-07-28T06:04:22.723Z" }, + { url = "https://files.pythonhosted.org/packages/49/2c/bc508fee75eb2919ed69769800b09968e4aab16897f909a23f39c81e323f/wrapt-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0077f3d65541925fa83002f967b22ad6550d24813ac64cb905f717194128d9c", size = 162922, upload-time = "2026-07-28T06:04:24.177Z" }, + { url = "https://files.pythonhosted.org/packages/4d/e5/04f34d38e66d857dfc2fc4088d60e70c0e422467822defa49b2b4a26e17b/wrapt-2.3.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9790ea25190a4e0fe4cdf4eeb868e9d75f8a024a70a5b6bf9c348a3a2b72e731", size = 156125, upload-time = "2026-07-28T06:04:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/23/41/c35940ea1c423f129ebe4361db853bc80d4def6326242e1206fa15bf94f4/wrapt-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:816877aa749253149f9ecfd2635d4d948ecfa338e1a0311d187b1acb1bb8a3eb", size = 162039, upload-time = "2026-07-28T06:04:27.154Z" }, + { url = "https://files.pythonhosted.org/packages/0e/60/9bda34c3d7d182aa703fe35339ae0ed4c4dad5e5c587f93890143e1f87fb/wrapt-2.3.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3d1c2c1b808600d2ea808e6360910a60ed5f409a4011655e10f9164ba0a414a6", size = 155110, upload-time = "2026-07-28T06:04:28.497Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ba/60bfd9b1a751f4fcb2d603668fc272d651ccdd339a56acf8c40ad21a0293/wrapt-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5ba1e5e08ddc46130e9682b2c249f2d1dd39bda9106ed4bd401b7519f18f41bd", size = 161089, upload-time = "2026-07-28T06:04:29.959Z" }, + { url = "https://files.pythonhosted.org/packages/0f/32/2bd358c6f4f1305c813479d1e9ba746bebdd794f4a20107ab2b3ee0cbd45/wrapt-2.3.0-cp311-cp311-win32.whl", hash = "sha256:45c9279b373d15649dfa2c2077cb3408ea1a6d3125afbdab9d6b809a66f68e14", size = 78030, upload-time = "2026-07-28T06:04:31.241Z" }, + { url = "https://files.pythonhosted.org/packages/4a/62/ecc969b13b141fef89b888c9760821cb01a86ac8fc953911592c8e1e1522/wrapt-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:195b1842b4122fb54e3cd3dd5b2b4aa49302a5a61da901df0481f5c97aedde84", size = 80944, upload-time = "2026-07-28T06:04:32.655Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3d/9278ada8a2b3f24372b630361e84e9a7de7abc3784634860c26d1c37785a/wrapt-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:6db604ef0c67bdb2042ecdfd7b7f037cf09733557ca42360d1018285634f7b98", size = 80074, upload-time = "2026-07-28T06:04:33.811Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4a/d17a0fad1bf1c5f2c887ff71fef75654141b0880bff71d157d955b5bec3a/wrapt-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0a45ffae742ce91a16e11cb6c7cd71e7f9994f3cbd283b962ab093f5c6dcf525", size = 82139, upload-time = "2026-07-28T06:04:35.082Z" }, + { url = "https://files.pythonhosted.org/packages/6e/55/51b92daaf6defb57f4dc56bdcce985400f75c6984a03ca5e78ccac717028/wrapt-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:69e477046f2237ef0bc6547544ee73008dc764ca26eff44f09e976d221b34d5d", size = 82723, upload-time = "2026-07-28T06:04:36.502Z" }, + { url = "https://files.pythonhosted.org/packages/28/7f/cfd9bc4b1f5e424eeea83d0493e43f3b1b02707ce8e50c47945873982bd5/wrapt-2.3.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d221a6e6ddd302b8397433184e96b59f259f50024b854db1c411a881586b6b8", size = 172381, upload-time = "2026-07-28T06:04:37.674Z" }, + { url = "https://files.pythonhosted.org/packages/cb/89/ff7814f6eb6856b479946117d1138a2fbb46cdb6b1f379db359056c69743/wrapt-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:392158c9a7f2ab1b8699418bfc0fe6f83548788c418b27d7bf2019ad3405cebb", size = 174120, upload-time = "2026-07-28T06:04:38.987Z" }, + { url = "https://files.pythonhosted.org/packages/12/1e/8eded8615d39e3ce81f626937a3a87b280a2a86239a2bf14a4b4bb345034/wrapt-2.3.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e5301c35cf75655eb33498f2bd6ae8703ca19940e3167dc9cdf740c712a39c60", size = 163035, upload-time = "2026-07-28T06:04:40.361Z" }, + { url = "https://files.pythonhosted.org/packages/35/ea/a0af2d9da62897af2a055484920de05dade30d2ba2c0d65cbdea875d3d8b/wrapt-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:418f54bb09d1762db02c7009b4051149893af3153a87f92d70356703c11eea02", size = 171887, upload-time = "2026-07-28T06:04:41.614Z" }, + { url = "https://files.pythonhosted.org/packages/7e/dd/63cd4c864c65ef4906df64bd2d378f4a62b54f28063f282dfb3bf93caead/wrapt-2.3.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1598becd30f8f2777d18564064eb4f4dbe1ab0e05a8f09786d0ef505ac782bf3", size = 161113, upload-time = "2026-07-28T06:04:42.864Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ee/82f1fc9e431b5c2c5a6d201aa865dbeae3984c311c6d11a185f0c8367cf6/wrapt-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3da470536bf9645143323dd41b32db55c6f4304ad382094c1a1da8a92061e10d", size = 170530, upload-time = "2026-07-28T06:04:44.212Z" }, + { url = "https://files.pythonhosted.org/packages/37/a5/5dc590e863a419930d988f8b7ca3e75a6befcfb10b6003b3a152f3d5f732/wrapt-2.3.0-cp312-cp312-win32.whl", hash = "sha256:fb8e2e6704a1e0b1b989546c69e2688371ef4a07fa5f61bde3eb6211186f5ac1", size = 78323, upload-time = "2026-07-28T06:04:45.484Z" }, + { url = "https://files.pythonhosted.org/packages/51/f9/4a6925a07951df56394f7e6ebe14f69f1c5ef9d87aa63e0839acf15aa63a/wrapt-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:cdc021cb0b62471d6aac7f2bd92f3b4658073775f9ee7fcd325c511129e7bcc8", size = 81180, upload-time = "2026-07-28T06:04:47.021Z" }, + { url = "https://files.pythonhosted.org/packages/a8/4f/8b5de0395b2a72216751d41c9861df6facaeb611b619d8810ed2b3b23eb2/wrapt-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:67bfe2485f50368c3fcd2275fc1fd100e350d601e0058921a7c82678a465aeab", size = 80155, upload-time = "2026-07-28T06:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6e/0f88a072483e76b881e3fdcd6b6ffb4a5791002514fe541e72b1b73c859a/wrapt-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d3fb71e65b001adfc42684522eeccd9c21d8ba679945abc993439567b66e59f", size = 81960, upload-time = "2026-07-28T06:04:49.622Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ff/b7e2776e7c294075eb712cc9ef573d1b818f393006d09787262b8fc871c4/wrapt-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:51a7a4181c1295774812271fbcd7c909df372bc25579d4ed9eb875caaf0ae86f", size = 82435, upload-time = "2026-07-28T06:04:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/d8/90/343bb5d0f1f9669bc252a6073f085b4abf862511bd5c9c9eaec754341f1d/wrapt-2.3.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9045917809c63fdf7abe3a2ceaed3d670b8ee4500ddd9291192d30aeb34467c5", size = 170350, upload-time = "2026-07-28T06:04:52.187Z" }, + { url = "https://files.pythonhosted.org/packages/59/f8/13b79a392930bd0dd6b86cbfbfe1c40944110456e1dc6d809e5c46ece904/wrapt-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54ca1d5573f69b5fe1d74f1f65799c68015e82f685efec9fd8cfa40a094c44d0", size = 170022, upload-time = "2026-07-28T06:04:53.599Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fc/4f1b6918f5290db959d6e0c07f77385d87cede29c39c9cf8f145e9c82954/wrapt-2.3.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:242b60c21e30866e6a2fa606c612b47c553fa60c0eaeeeb7797fb842ac0ce609", size = 161043, upload-time = "2026-07-28T06:04:54.936Z" }, + { url = "https://files.pythonhosted.org/packages/01/e1/45d3cf74414780bdff6d0380467e003f6eb0f028b6c9403db868dbc7209c/wrapt-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3f3d7ec0a51fbfe00d3aef047641ff2c58b25565b4717fc1f90e050be01cba8", size = 168576, upload-time = "2026-07-28T06:04:56.261Z" }, + { url = "https://files.pythonhosted.org/packages/f3/73/2fa58dd97f191c997755e2c6d569a68f0c433db4e4b36099bdd7227b6cac/wrapt-2.3.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:261f53870cd4fb2bf38f9f972c56c728fd224cb7c65721307de59d9e7e6741ae", size = 159140, upload-time = "2026-07-28T06:04:57.754Z" }, + { url = "https://files.pythonhosted.org/packages/29/a8/08a56e2000a8816d449dcbad8c8b081697acbbd490821ceca0f9d8e8d20c/wrapt-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8159ec0b0cb7608175eb150de94c19e34f4d47ac655f5ca9baf45df6b688ffd3", size = 169263, upload-time = "2026-07-28T06:04:59.161Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d4/354e1725e35a73b2af4fa70a3e024c7a5d1bf1802dfb862dcb668aae0253/wrapt-2.3.0-cp313-cp313-win32.whl", hash = "sha256:10461884b3014fbfc8eb7d09a93c5f246363e6711d9d881f95eb8c27fdef049f", size = 78241, upload-time = "2026-07-28T06:05:00.507Z" }, + { url = "https://files.pythonhosted.org/packages/6c/7e/34c87fa2174848dfee820322aaa318bab08913998ccecc8d2f57b4ad4639/wrapt-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:ac870cc97b73bb00ac353329e9559a4bebc47c4c86792ed9b23b58c15b6ad838", size = 81113, upload-time = "2026-07-28T06:05:01.839Z" }, + { url = "https://files.pythonhosted.org/packages/11/86/fcc9a530579e008c9478bb565a6cdfbfd33536660f069c8b91a6607c5050/wrapt-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:a65e8db2b4e90c2e7ade931086351c98ef420bf7a94ee08c95ac8a3cbbc43579", size = 80182, upload-time = "2026-07-28T06:05:03.152Z" }, + { url = "https://files.pythonhosted.org/packages/96/50/3864848b95b28ef73e17551fc8dccbff2628a834f52cf26a57f9c419fb83/wrapt-2.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:fd1f2f557dd3491fe75905e578f4db967393d40d1a8f468edc4d40ac7f2d5944", size = 83921, upload-time = "2026-07-28T06:05:04.476Z" }, + { url = "https://files.pythonhosted.org/packages/3b/4c/3d1921a60c3e8c71c540ff136e6a47a1fbccf7f671e818394889f7871d9c/wrapt-2.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9f5d2aec29dfc76c37e23897dee92766a3fd4f3bff3ae7fc9c6b4bf37d8c1360", size = 84412, upload-time = "2026-07-28T06:05:05.921Z" }, + { url = "https://files.pythonhosted.org/packages/fa/1a/4a796ff7adb26ada6d4b758c94d47a38320b085e7099afc088efbbcdb006/wrapt-2.3.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:646d20d413ffcd1b0a2f700076e2d0252d872dcb7754860a73e45a59ea883614", size = 207168, upload-time = "2026-07-28T06:05:07.256Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3e/d7777776806c579b761bac2f91721dda9f04c7a1b380213c5935cc750ae6/wrapt-2.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:379f670f45b7bb8993edd9f6fc36c6cc65edb81cffa0b504be34acb0303fff0a", size = 214351, upload-time = "2026-07-28T06:05:08.945Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/2d64d394df7bf181955b3bb562bf33c4492fb4be113f53071106d43ad8b5/wrapt-2.3.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6208f302f110295d64b22a7ac96500c791bf492dce4366e622e4912b077c9687", size = 199020, upload-time = "2026-07-28T06:05:10.418Z" }, + { url = "https://files.pythonhosted.org/packages/3e/3d/fb31d3db7d9834d265fb1a27a2adf0ddf51557c67458c97b22439ad6ae3d/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ed635a9ca4f3a5a2b900c10c69e823373bc00ebc114b459383596d3487da3570", size = 209969, upload-time = "2026-07-28T06:05:11.983Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d1/8724b5da582e62070dc9bf4d8bf1972f317297eefd7ba1f2b5c6393ccf6c/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:e3b9eaa742ae7a0aaaaad4ca4b69469d757af2d6e6663ef1dadc47adec0aeb41", size = 196324, upload-time = "2026-07-28T06:05:13.557Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/3d9ef411149543016ee6bcf3af707f787cebd946527452b94bf122e9b7b4/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d0f7284f88f4833705132d06d3b425a43095c2cbd07c58166aac3ab646ba12a4", size = 202610, upload-time = "2026-07-28T06:05:15.048Z" }, + { url = "https://files.pythonhosted.org/packages/13/9b/4fc042ceb757866dd4a5fc057b3b736f2b360d3703ce9f830d83dc9226e0/wrapt-2.3.0-cp313-cp313t-win32.whl", hash = "sha256:7ebb274aba688b043429eb1500ff8a76ce0cb8ac0812ca3e301f06247b8722b3", size = 79178, upload-time = "2026-07-28T06:05:16.469Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ff/b94878f8eed809ca042685276bcea9f24e8c2ca7c9653bb80bbb920a68a5/wrapt-2.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c4bded758ad6f03b965830944a2f0bc5b2eb3767fe5a7310134315d1a6610e98", size = 82634, upload-time = "2026-07-28T06:05:18.026Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/663e1de5332a71685a729754312d327d4cada767c36e1c5a2db4c8de49e6/wrapt-2.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:d2cc64539da63e39ffb9c7ede849b6e8ddaaf7b3876b5cfb04efd85a5f3f4eb6", size = 81387, upload-time = "2026-07-28T06:05:19.417Z" }, + { url = "https://files.pythonhosted.org/packages/58/10/b073beaea89bc0d3670a75ff51139430a54b6af7ba7796507730634536dd/wrapt-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea52a0d0f08c584943d5764be0e84efa912c8da23c23e1e285ff2f5641c18fcc", size = 81978, upload-time = "2026-07-28T06:05:21.133Z" }, + { url = "https://files.pythonhosted.org/packages/b3/31/0916d9cebf848ed3f1a0c1888faee421747df77331e4db2bc527a9a85988/wrapt-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd85b0aa88efdb189d6ae2f35f4526943a8f091c38599c9c31478241c819e6a1", size = 82518, upload-time = "2026-07-28T06:05:22.562Z" }, + { url = "https://files.pythonhosted.org/packages/f5/73/31c1bf0f3384062751c2094dadb314916d70aa9b6bfd26d994b4a7b393fa/wrapt-2.3.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:141ed6211286a9660d8d6702de598b43f0934b4f0eda16393f100a80f501d945", size = 170187, upload-time = "2026-07-28T06:05:23.904Z" }, + { url = "https://files.pythonhosted.org/packages/ed/25/fce087d54b79b8905f3c3c9dd5f454bbd8d8acb80b960c4a6aee5b4659b3/wrapt-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e49885a62ec4ee854d1b9e6371fda6afd219917225752abf729a3f36d4df9a5", size = 169288, upload-time = "2026-07-28T06:05:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/c7/30/0d09e6dddc6b7a7230ac77f50254b5980ab4fcd22976f72f8cc8a0404458/wrapt-2.3.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d6159c9b2fefec02314e1332dbbbfaf960e369dfd26bcf7f8b258b5732065b3", size = 160932, upload-time = "2026-07-28T06:05:27.022Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ca/0913af0d2ec0c43865d32d615f518fea66c13c5c930e489e9b0de248e9a8/wrapt-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:24da48596326ef8e448cfa837b454f638713d3531262375f00e5a9681682fc07", size = 169017, upload-time = "2026-07-28T06:05:28.501Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f2/3d1e47ea81b822210f5df1bf942fd90780a75c055243d569b664529dea88/wrapt-2.3.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cd3a2edf0427013736b8127955cec62608c56e53ea47e82812ea32059cda407f", size = 159065, upload-time = "2026-07-28T06:05:30.01Z" }, + { url = "https://files.pythonhosted.org/packages/43/a5/ef2066ced8e5fca204e2b361e9708e36555b40949c583d997ea3b590817d/wrapt-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fa0df3bff4e7ce45759f33fd39335fe2f60477bb9ecf7b8aa41e7d07ee36a23", size = 168821, upload-time = "2026-07-28T06:05:31.649Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e1/016104650d4e572fa91506eb396b3dd8efbccc9284fdc1c9479c3d21db28/wrapt-2.3.0-cp314-cp314-win32.whl", hash = "sha256:2935d5454b3f179a29b12cf390ee47246740ba2c3a7545b1b46ba31a5f2a4a0b", size = 78700, upload-time = "2026-07-28T06:05:33.391Z" }, + { url = "https://files.pythonhosted.org/packages/3d/97/6fdc20a9f2ca304748b3f0819cbf377d55260562777bf0b615431bc3c181/wrapt-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:cc2cea812e5cb179a796b766747e7d3b21088760d8deb95676d482b8c8e6fa7d", size = 81422, upload-time = "2026-07-28T06:05:34.774Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a4/9cbd53bf05746bea2c392af39cb052427a8ec95cbd494d930733d8f44681/wrapt-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:22cc5c0a717bd4da87018ae0bffd4c19c6fb679d3ff357216ba566ab26c76cab", size = 80639, upload-time = "2026-07-28T06:05:36.228Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/6c5e4a0f66ea0d2b2dd267e8dd05a0014eea56840b3c8595d40b0a5d1f91/wrapt-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a6b5984cd65dd639546f0eb4b8eacf1c31cb2fe9fb5c27bffe240987cdb2cf84", size = 84030, upload-time = "2026-07-28T06:05:37.714Z" }, + { url = "https://files.pythonhosted.org/packages/6a/eb/a1aedf03283bc9cbf8a1783995ddc54e3c5a86878f19002d2c428494f4c5/wrapt-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c88abcf53daef80e01a75c7530e727fa6e2c1888fe83e3dcdba4c96216a1f5c7", size = 84419, upload-time = "2026-07-28T06:05:39.131Z" }, + { url = "https://files.pythonhosted.org/packages/63/61/50d511c0dc5105563849e86daa3e16ac7feef699f79fb05af45ea70107d5/wrapt-2.3.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:85de890ff968196e92dd1ae73a9fb8970495e7650a457b1c9ef0ac3dd550bce2", size = 207171, upload-time = "2026-07-28T06:05:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/3f/59/9b538cf7795217e810699d16bc88b96a830d9b5c403eb2ec2db6b5f2ae81/wrapt-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50f416b74d092bb9f41b424e90dd457f365f7ba4b11de62a23679769a21bd85c", size = 214329, upload-time = "2026-07-28T06:05:42.287Z" }, + { url = "https://files.pythonhosted.org/packages/b3/28/9935d62b1499e5c8b3d191e99ba4eb31ca237a0b699142011a837e9dc7ea/wrapt-2.3.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39febbee6d77301d31da6996b152ce52452da7c7ef72aba10c2fa976dff9c295", size = 199079, upload-time = "2026-07-28T06:05:43.958Z" }, + { url = "https://files.pythonhosted.org/packages/2b/01/4446b80fa2ffa47a3449b250d004ba1c1937f07f64a179608fec735df866/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:93513bec052c6cd987f9f580c3df068c8bc4ebae6543736be3ca7ec5959cafcd", size = 209992, upload-time = "2026-07-28T06:05:45.677Z" }, + { url = "https://files.pythonhosted.org/packages/d4/07/56f26c9f9979586a021e8148747004aba4498f49458c90b0502969b904e1/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:729126e667da34d251b8ebf8a45ef0c5ddadc21542b3d6e1abf4259ece6508df", size = 196334, upload-time = "2026-07-28T06:05:47.608Z" }, + { url = "https://files.pythonhosted.org/packages/8b/41/6d7bcc895b0f28b2250e10908f060687b9165429dcd7f22ddb3d4c031b74/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:626b69db2021aa01671ec7bbc9740e558522bd44c18cf2ce69bf3d666a014109", size = 202644, upload-time = "2026-07-28T06:05:49.183Z" }, + { url = "https://files.pythonhosted.org/packages/cd/25/7860927edba06b758b8852a6f02e832be715563c67a6795d94350bc81099/wrapt-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:629d73378082c00a8173031f9fb30a3ac6abbc894a5bfdfae71fabc60642d501", size = 79685, upload-time = "2026-07-28T06:05:50.976Z" }, + { url = "https://files.pythonhosted.org/packages/c4/0f/270bafe92fde3b069a39bc01e39ee79340895b335640df861d43d2a51885/wrapt-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:42869085687f0aefd57c0f636c3f9354f8ffb321a8ba9cb52d19beb796e561c5", size = 83104, upload-time = "2026-07-28T06:05:52.405Z" }, + { url = "https://files.pythonhosted.org/packages/55/b3/af176d79a8515a8a720eccdad9a96f6e31a30abf2865430c8c42adf2fd13/wrapt-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b1e5aa486e269b00ed35e64771c7d0ab8096cfd2643405ca8cd60ebedc099a51", size = 81774, upload-time = "2026-07-28T06:05:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/40/99/b44e9dc20c8d768ffe65174bfebde1412068fc1638aac436eccf1e7a603a/wrapt-2.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c3b476ae63b4a3b4da681aafcb25ff3542d289fbda8b5da7caf76aaffafafdbb", size = 81227, upload-time = "2026-07-28T06:05:55.438Z" }, + { url = "https://files.pythonhosted.org/packages/7e/cb/1e1bbdb39ea166b4b2568c5eec3d82f59cddecf1ed57e5c4a1ed54692107/wrapt-2.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:932dced0a7b2950ed58a3325536a1dcb7b58e7330af54e8552d2e566b5328b99", size = 82284, upload-time = "2026-07-28T06:05:56.879Z" }, + { url = "https://files.pythonhosted.org/packages/54/0a/935b037716e02376415dbc9fe95e523c64111c223a0de4dc2e12c1e1ee20/wrapt-2.3.0-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0db083387d6e75ec0be8173ecbf0e811cf60bae1cc75a815feb104167ea10d4d", size = 154975, upload-time = "2026-07-28T06:05:58.369Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ed/6222b5e4ab73a0185d77e3490dbdd372dc1cd961acbcdc62b0bc345a8d2c/wrapt-2.3.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abc71504669d126d91f89fc0e388c6295d8fbd2439be884f175133fda8aa403c", size = 157056, upload-time = "2026-07-28T06:06:00.038Z" }, + { url = "https://files.pythonhosted.org/packages/20/f8/eac651ecc80db2c7ac697111411de05fd4d4f9eea557d32b9bb11e1ada5c/wrapt-2.3.0-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b767a9566f165dd14decf8f4194c6bb0ce3a8420cec213824e05a99400c9260a", size = 151513, upload-time = "2026-07-28T06:06:01.808Z" }, + { url = "https://files.pythonhosted.org/packages/e1/32/ed810ea37c2b4b9948bf23def5954a1848d98021602dd7220db3f8ee1a58/wrapt-2.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:73d0b10b64620a2cf4bc3d31775c4d9527e309a5549e4379e3bf71e8d2dc193e", size = 156054, upload-time = "2026-07-28T06:06:03.467Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8e/facfaa9b2d4eda4f14fb5f88fc493947d1513cec28538613082e1663037d/wrapt-2.3.0-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:e31734c5077f29f892b2565eee5106d610278151ad49fc6a9d69a647cd5730e2", size = 150821, upload-time = "2026-07-28T06:06:05.152Z" }, + { url = "https://files.pythonhosted.org/packages/5a/7e/14f4b6f2d9a89186f35f04dd1ec6aad41ffa439dac16c88fc41f3756b9a6/wrapt-2.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:628f3ba8ec793a5b10a6cd8c6c6b7b55eb552abd1f3bd301336acb74c7a82dfe", size = 154303, upload-time = "2026-07-28T06:06:06.808Z" }, + { url = "https://files.pythonhosted.org/packages/4b/90/e25fd18051bc83a1f7c62edd184a262a3f593716126619ebbc79de801e6a/wrapt-2.3.0-cp39-cp39-win32.whl", hash = "sha256:3873c3c5ca9f4ef91f693602eca19d1f1e7c410338df82a4ff11d826b5896a8f", size = 77908, upload-time = "2026-07-28T06:06:08.384Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2c/40f523565f1aed94d0030f4f99f481adc5b5620ee8600bfcd46effb49a79/wrapt-2.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:8f8a1c6472675956cece9a8f403f43c3594f1681319eed2dd56f60877397c636", size = 80796, upload-time = "2026-07-28T06:06:09.906Z" }, + { url = "https://files.pythonhosted.org/packages/3f/91/a86501de81265751a42b3c3f977e6c88d580936dd180e3298e2bd813e0d4/wrapt-2.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:c8858d8ff9822a081e3cc49ae1b3b22f0f789c14001cdac8f94564010d9c9d66", size = 80227, upload-time = "2026-07-28T06:06:11.413Z" }, + { url = "https://files.pythonhosted.org/packages/00/39/3daf9f47be208606586de4568ba6713db53ebc8fd7a575aea1fe57983b69/wrapt-2.3.0-py3-none-any.whl", hash = "sha256:d8c7ed08477429752b8c44991f40ad7838b18332a160698740a6bfbc10d998a2", size = 61866, upload-time = "2026-07-28T06:06:12.9Z" }, +] + +[[package]] +name = "yarl" +version = "1.22.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9.12' and python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.9.12'", + "python_full_version <= '3.9'", +] +dependencies = [ + { name = "idna", marker = "python_full_version < '3.10'" }, + { name = "multidict", marker = "python_full_version < '3.10'" }, + { name = "propcache", version = "0.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/43/a2204825342f37c337f5edb6637040fa14e365b2fcc2346960201d457579/yarl-1.22.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c7bd6683587567e5a49ee6e336e0612bec8329be1b7d4c8af5687dcdeb67ee1e", size = 140517, upload-time = "2025-10-06T14:08:42.494Z" }, + { url = "https://files.pythonhosted.org/packages/44/6f/674f3e6f02266428c56f704cd2501c22f78e8b2eeb23f153117cc86fb28a/yarl-1.22.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5cdac20da754f3a723cceea5b3448e1a2074866406adeb4ef35b469d089adb8f", size = 93495, upload-time = "2025-10-06T14:08:46.2Z" }, + { url = "https://files.pythonhosted.org/packages/b8/12/5b274d8a0f30c07b91b2f02cba69152600b47830fcfb465c108880fcee9c/yarl-1.22.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07a524d84df0c10f41e3ee918846e1974aba4ec017f990dc735aad487a0bdfdf", size = 94400, upload-time = "2025-10-06T14:08:47.855Z" }, + { url = "https://files.pythonhosted.org/packages/e2/7f/df1b6949b1fa1aa9ff6de6e2631876ad4b73c4437822026e85d8acb56bb1/yarl-1.22.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1b329cb8146d7b736677a2440e422eadd775d1806a81db2d4cded80a48efc1a", size = 347545, upload-time = "2025-10-06T14:08:49.683Z" }, + { url = "https://files.pythonhosted.org/packages/84/09/f92ed93bd6cd77872ab6c3462df45ca45cd058d8f1d0c9b4f54c1704429f/yarl-1.22.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75976c6945d85dbb9ee6308cd7ff7b1fb9409380c82d6119bd778d8fcfe2931c", size = 319598, upload-time = "2025-10-06T14:08:51.215Z" }, + { url = "https://files.pythonhosted.org/packages/c3/97/ac3f3feae7d522cf7ccec3d340bb0b2b61c56cb9767923df62a135092c6b/yarl-1.22.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:80ddf7a5f8c86cb3eb4bc9028b07bbbf1f08a96c5c0bc1244be5e8fefcb94147", size = 363893, upload-time = "2025-10-06T14:08:53.144Z" }, + { url = "https://files.pythonhosted.org/packages/06/49/f3219097403b9c84a4d079b1d7bda62dd9b86d0d6e4428c02d46ab2c77fc/yarl-1.22.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d332fc2e3c94dad927f2112395772a4e4fedbcf8f80efc21ed7cdfae4d574fdb", size = 371240, upload-time = "2025-10-06T14:08:55.036Z" }, + { url = "https://files.pythonhosted.org/packages/35/9f/06b765d45c0e44e8ecf0fe15c9eacbbde342bb5b7561c46944f107bfb6c3/yarl-1.22.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cf71bf877efeac18b38d3930594c0948c82b64547c1cf420ba48722fe5509f6", size = 346965, upload-time = "2025-10-06T14:08:56.722Z" }, + { url = "https://files.pythonhosted.org/packages/c5/69/599e7cea8d0fcb1694323b0db0dda317fa3162f7b90166faddecf532166f/yarl-1.22.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:663e1cadaddae26be034a6ab6072449a8426ddb03d500f43daf952b74553bba0", size = 342026, upload-time = "2025-10-06T14:08:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/95/6f/9dfd12c8bc90fea9eab39832ee32ea48f8e53d1256252a77b710c065c89f/yarl-1.22.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:6dcbb0829c671f305be48a7227918cfcd11276c2d637a8033a99a02b67bf9eda", size = 335637, upload-time = "2025-10-06T14:09:00.506Z" }, + { url = "https://files.pythonhosted.org/packages/57/2e/34c5b4eb9b07e16e873db5b182c71e5f06f9b5af388cdaa97736d79dd9a6/yarl-1.22.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f0d97c18dfd9a9af4490631905a3f131a8e4c9e80a39353919e2cfed8f00aedc", size = 359082, upload-time = "2025-10-06T14:09:01.936Z" }, + { url = "https://files.pythonhosted.org/packages/31/71/fa7e10fb772d273aa1f096ecb8ab8594117822f683bab7d2c5a89914c92a/yarl-1.22.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:437840083abe022c978470b942ff832c3940b2ad3734d424b7eaffcd07f76737", size = 357811, upload-time = "2025-10-06T14:09:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/26/da/11374c04e8e1184a6a03cf9c8f5688d3e5cec83ed6f31ad3481b3207f709/yarl-1.22.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a899cbd98dce6f5d8de1aad31cb712ec0a530abc0a86bd6edaa47c1090138467", size = 351223, upload-time = "2025-10-06T14:09:05.401Z" }, + { url = "https://files.pythonhosted.org/packages/82/8f/e2d01f161b0c034a30410e375e191a5d27608c1f8693bab1a08b089ca096/yarl-1.22.0-cp310-cp310-win32.whl", hash = "sha256:595697f68bd1f0c1c159fcb97b661fc9c3f5db46498043555d04805430e79bea", size = 82118, upload-time = "2025-10-06T14:09:11.148Z" }, + { url = "https://files.pythonhosted.org/packages/62/46/94c76196642dbeae634c7a61ba3da88cd77bed875bf6e4a8bed037505aa6/yarl-1.22.0-cp310-cp310-win_amd64.whl", hash = "sha256:cb95a9b1adaa48e41815a55ae740cfda005758104049a640a398120bf02515ca", size = 86852, upload-time = "2025-10-06T14:09:12.958Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/7df4f179d3b1a6dcb9a4bd2ffbc67642746fcafdb62580e66876ce83fff4/yarl-1.22.0-cp310-cp310-win_arm64.whl", hash = "sha256:b85b982afde6df99ecc996990d4ad7ccbdbb70e2a4ba4de0aecde5922ba98a0b", size = 82012, upload-time = "2025-10-06T14:09:14.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/27/5ab13fc84c76a0250afd3d26d5936349a35be56ce5785447d6c423b26d92/yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511", size = 141607, upload-time = "2025-10-06T14:09:16.298Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a1/d065d51d02dc02ce81501d476b9ed2229d9a990818332242a882d5d60340/yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6", size = 94027, upload-time = "2025-10-06T14:09:17.786Z" }, + { url = "https://files.pythonhosted.org/packages/c1/da/8da9f6a53f67b5106ffe902c6fa0164e10398d4e150d85838b82f424072a/yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028", size = 94963, upload-time = "2025-10-06T14:09:19.662Z" }, + { url = "https://files.pythonhosted.org/packages/68/fe/2c1f674960c376e29cb0bec1249b117d11738db92a6ccc4a530b972648db/yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d", size = 368406, upload-time = "2025-10-06T14:09:21.402Z" }, + { url = "https://files.pythonhosted.org/packages/95/26/812a540e1c3c6418fec60e9bbd38e871eaba9545e94fa5eff8f4a8e28e1e/yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503", size = 336581, upload-time = "2025-10-06T14:09:22.98Z" }, + { url = "https://files.pythonhosted.org/packages/0b/f5/5777b19e26fdf98563985e481f8be3d8a39f8734147a6ebf459d0dab5a6b/yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65", size = 388924, upload-time = "2025-10-06T14:09:24.655Z" }, + { url = "https://files.pythonhosted.org/packages/86/08/24bd2477bd59c0bbd994fe1d93b126e0472e4e3df5a96a277b0a55309e89/yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e", size = 392890, upload-time = "2025-10-06T14:09:26.617Z" }, + { url = "https://files.pythonhosted.org/packages/46/00/71b90ed48e895667ecfb1eaab27c1523ee2fa217433ed77a73b13205ca4b/yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d", size = 365819, upload-time = "2025-10-06T14:09:28.544Z" }, + { url = "https://files.pythonhosted.org/packages/30/2d/f715501cae832651d3282387c6a9236cd26bd00d0ff1e404b3dc52447884/yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7", size = 363601, upload-time = "2025-10-06T14:09:30.568Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f9/a678c992d78e394e7126ee0b0e4e71bd2775e4334d00a9278c06a6cce96a/yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967", size = 358072, upload-time = "2025-10-06T14:09:32.528Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d1/b49454411a60edb6fefdcad4f8e6dbba7d8019e3a508a1c5836cba6d0781/yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed", size = 385311, upload-time = "2025-10-06T14:09:34.634Z" }, + { url = "https://files.pythonhosted.org/packages/87/e5/40d7a94debb8448c7771a916d1861d6609dddf7958dc381117e7ba36d9e8/yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6", size = 381094, upload-time = "2025-10-06T14:09:36.268Z" }, + { url = "https://files.pythonhosted.org/packages/35/d8/611cc282502381ad855448643e1ad0538957fc82ae83dfe7762c14069e14/yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e", size = 370944, upload-time = "2025-10-06T14:09:37.872Z" }, + { url = "https://files.pythonhosted.org/packages/2d/df/fadd00fb1c90e1a5a8bd731fa3d3de2e165e5a3666a095b04e31b04d9cb6/yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca", size = 81804, upload-time = "2025-10-06T14:09:39.359Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f7/149bb6f45f267cb5c074ac40c01c6b3ea6d8a620d34b337f6321928a1b4d/yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b", size = 86858, upload-time = "2025-10-06T14:09:41.068Z" }, + { url = "https://files.pythonhosted.org/packages/2b/13/88b78b93ad3f2f0b78e13bfaaa24d11cbc746e93fe76d8c06bf139615646/yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376", size = 81637, upload-time = "2025-10-06T14:09:42.712Z" }, + { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" }, + { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" }, + { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" }, + { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" }, + { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" }, + { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" }, + { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" }, + { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" }, + { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" }, + { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" }, + { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" }, + { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" }, + { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" }, + { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" }, + { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" }, + { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" }, + { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" }, + { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" }, + { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" }, + { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" }, + { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" }, + { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" }, + { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" }, + { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" }, + { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" }, + { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" }, + { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" }, + { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" }, + { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" }, + { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" }, + { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" }, + { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" }, + { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" }, + { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" }, + { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" }, + { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" }, + { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" }, + { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" }, + { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" }, + { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" }, + { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" }, + { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" }, + { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" }, + { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" }, + { url = "https://files.pythonhosted.org/packages/94/fd/6480106702a79bcceda5fd9c63cb19a04a6506bd5ce7fd8d9b63742f0021/yarl-1.22.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3aa27acb6de7a23785d81557577491f6c38a5209a254d1191519d07d8fe51748", size = 141301, upload-time = "2025-10-06T14:12:19.01Z" }, + { url = "https://files.pythonhosted.org/packages/42/e1/6d95d21b17a93e793e4ec420a925fe1f6a9342338ca7a563ed21129c0990/yarl-1.22.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:af74f05666a5e531289cb1cc9c883d1de2088b8e5b4de48004e5ca8a830ac859", size = 93864, upload-time = "2025-10-06T14:12:21.05Z" }, + { url = "https://files.pythonhosted.org/packages/32/58/b8055273c203968e89808413ea4c984988b6649baabf10f4522e67c22d2f/yarl-1.22.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:62441e55958977b8167b2709c164c91a6363e25da322d87ae6dd9c6019ceecf9", size = 94706, upload-time = "2025-10-06T14:12:23.287Z" }, + { url = "https://files.pythonhosted.org/packages/18/91/d7bfbc28a88c2895ecd0da6a874def0c147de78afc52c773c28e1aa233a3/yarl-1.22.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b580e71cac3f8113d3135888770903eaf2f507e9421e5697d6ee6d8cd1c7f054", size = 347100, upload-time = "2025-10-06T14:12:28.527Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e8/37a1e7b99721c0564b1fc7b0a4d1f595ef6fb8060d82ca61775b644185f7/yarl-1.22.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e81fda2fb4a07eda1a2252b216aa0df23ebcd4d584894e9612e80999a78fd95b", size = 318902, upload-time = "2025-10-06T14:12:30.528Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ef/34724449d7ef2db4f22df644f2dac0b8a275d20f585e526937b3ae47b02d/yarl-1.22.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99b6fc1d55782461b78221e95fc357b47ad98b041e8e20f47c1411d0aacddc60", size = 363302, upload-time = "2025-10-06T14:12:32.295Z" }, + { url = "https://files.pythonhosted.org/packages/8a/04/88a39a5dad39889f192cce8d66cc4c58dbeca983e83f9b6bf23822a7ed91/yarl-1.22.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:088e4e08f033db4be2ccd1f34cf29fe994772fb54cfe004bbf54db320af56890", size = 370816, upload-time = "2025-10-06T14:12:34.01Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1f/5e895e547129413f56c76be2c3ce4b96c797d2d0ff3e16a817d9269b12e6/yarl-1.22.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e4e1f6f0b4da23e61188676e3ed027ef0baa833a2e633c29ff8530800edccba", size = 346465, upload-time = "2025-10-06T14:12:35.977Z" }, + { url = "https://files.pythonhosted.org/packages/11/13/a750e9fd6f9cc9ed3a52a70fe58ffe505322f0efe0d48e1fd9ffe53281f5/yarl-1.22.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:84fc3ec96fce86ce5aa305eb4aa9358279d1aa644b71fab7b8ed33fe3ba1a7ca", size = 341506, upload-time = "2025-10-06T14:12:37.788Z" }, + { url = "https://files.pythonhosted.org/packages/3c/67/bb6024de76e7186611ebe626aec5b71a2d2ecf9453e795f2dbd80614784c/yarl-1.22.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5dbeefd6ca588b33576a01b0ad58aa934bc1b41ef89dee505bf2932b22ddffba", size = 335030, upload-time = "2025-10-06T14:12:39.775Z" }, + { url = "https://files.pythonhosted.org/packages/a2/be/50b38447fd94a7992996a62b8b463d0579323fcfc08c61bdba949eef8a5d/yarl-1.22.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:14291620375b1060613f4aab9ebf21850058b6b1b438f386cc814813d901c60b", size = 358560, upload-time = "2025-10-06T14:12:41.547Z" }, + { url = "https://files.pythonhosted.org/packages/e2/89/c020b6f547578c4e3dbb6335bf918f26e2f34ad0d1e515d72fd33ac0c635/yarl-1.22.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:a4fcfc8eb2c34148c118dfa02e6427ca278bfd0f3df7c5f99e33d2c0e81eae3e", size = 357290, upload-time = "2025-10-06T14:12:43.861Z" }, + { url = "https://files.pythonhosted.org/packages/8c/52/c49a619ee35a402fa3a7019a4fa8d26878fec0d1243f6968bbf516789578/yarl-1.22.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:029866bde8d7b0878b9c160e72305bbf0a7342bcd20b9999381704ae03308dc8", size = 350700, upload-time = "2025-10-06T14:12:46.868Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c9/f5042d87777bf6968435f04a2bbb15466b2f142e6e47fa4f34d1a3f32f0c/yarl-1.22.0-cp39-cp39-win32.whl", hash = "sha256:4dcc74149ccc8bba31ce1944acee24813e93cfdee2acda3c172df844948ddf7b", size = 82323, upload-time = "2025-10-06T14:12:48.633Z" }, + { url = "https://files.pythonhosted.org/packages/fd/58/d00f7cad9eba20c4eefac2682f34661d1d1b3a942fc0092eb60e78cfb733/yarl-1.22.0-cp39-cp39-win_amd64.whl", hash = "sha256:10619d9fdee46d20edc49d3479e2f8269d0779f1b031e6f7c2aa1c76be04b7ed", size = 87145, upload-time = "2025-10-06T14:12:50.241Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a3/70904f365080780d38b919edd42d224b8c4ce224a86950d2eaa2a24366ad/yarl-1.22.0-cp39-cp39-win_arm64.whl", hash = "sha256:dd7afd3f8b0bfb4e0d9fc3c31bfe8a4ec7debe124cfd90619305def3c8ca8cd2", size = 82173, upload-time = "2025-10-06T14:12:51.869Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, +] + +[[package]] +name = "yarl" +version = "1.24.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "idna", marker = "python_full_version >= '3.10'" }, + { name = "multidict", marker = "python_full_version >= '3.10'" }, + { name = "propcache", version = "0.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/ac/cacdda1f0a90441297210bc34cf7e4ac1b7318c8030ebd83bdf6fe82f1db/yarl-1.24.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88f50c94e21a0a7f14042c015b0eba1881af78562e7bf007e0033e624da59750", size = 135466, upload-time = "2026-07-20T02:04:21.695Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a5/1b2ceace0230e40c52ab1b263148059a43a6303219b996affc68f8381836/yarl-1.24.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6efbccc3d7f75d5b03105172a8dc86d82ba4da86817952529dd93185f4a88be2", size = 97291, upload-time = "2026-07-20T02:04:24.045Z" }, + { url = "https://files.pythonhosted.org/packages/59/1d/340d1a0db7bbce1f291afc044255ebf4ebbce2b25ab1b3f7d3d069080f5d/yarl-1.24.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0ebfaffe1a16cb72141c8e09f18cc76856dbe58639f393a4f2b26e474b96b871", size = 97154, upload-time = "2026-07-20T02:04:25.761Z" }, + { url = "https://files.pythonhosted.org/packages/05/41/25596a33c2fb5098dca8dc3773b04221db64ded0b7f8f09885647d864610/yarl-1.24.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ac73abdc7ab75610f95a8fd994c6457e87752b02a63987e188f937a1fc180f0", size = 109196, upload-time = "2026-07-20T02:04:27.543Z" }, + { url = "https://files.pythonhosted.org/packages/f2/df/dd9f2fb8a5c6054fbefd1538d2b9b1127e612d2ee64b307a070173b57afd/yarl-1.24.5-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4d97a951a81039050e45f04e96689b58b8243fa5e62aa14fe67cb6075300885e", size = 102556, upload-time = "2026-07-20T02:04:29.16Z" }, + { url = "https://files.pythonhosted.org/packages/cb/57/4754b9d2c8945880290ecba0864e8b0441e117bba70534fe819e3645e174/yarl-1.24.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fe7b7bb170daccbba19ad33012d2b15f1e7942296fd4d45fc1b79013da8cc0f2", size = 117965, upload-time = "2026-07-20T02:04:30.845Z" }, + { url = "https://files.pythonhosted.org/packages/74/b5/6a9ece27d2043c3386f902dd078ab35d29ef5126b3206ebffb673283a7cb/yarl-1.24.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89a1bbb58e0e3f7a283653d854b1e95d65e5cfd4af224dac5f02629ec1a3e621", size = 116266, upload-time = "2026-07-20T02:04:32.573Z" }, + { url = "https://files.pythonhosted.org/packages/9e/bc/a6653249f6ee59ec85dcfec008d9cbc16586dad613963bb17a91b2b993a5/yarl-1.24.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fa5e51397466ea7e98de493fa2ff1b8193cfef8a7b0f9b4842f92d342df0dba", size = 110758, upload-time = "2026-07-20T02:04:34.235Z" }, + { url = "https://files.pythonhosted.org/packages/65/7e/c5a12fb8208df7b981bc82256e7831ce428eeaf893f7bbe6179c57bb9252/yarl-1.24.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4103b77b8a8225e413107d2349b65eb3c1c52627b5cc5c3c4c1c6a798b218950", size = 110120, upload-time = "2026-07-20T02:04:35.85Z" }, + { url = "https://files.pythonhosted.org/packages/04/6c/1b659b964626694667b3ec01bf4bcff564b73ae7c48ea1fbfe588b78b461/yarl-1.24.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f9f3e9c8a9ecffa57bef8fb4fa19e5fa4d2d8307cf6bac5b1fca5e5860f4ba00", size = 108834, upload-time = "2026-07-20T02:04:37.67Z" }, + { url = "https://files.pythonhosted.org/packages/74/a6/bf48f55c2104e40c15b7b13fad0a5756a11552a55f01c90bc90a66ab81c3/yarl-1.24.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0ebc836c47a6477e182169c6a476fc691d12b518894bf7dd2572f0d59f1c7ed", size = 103442, upload-time = "2026-07-20T02:04:39.576Z" }, + { url = "https://files.pythonhosted.org/packages/37/ac/84b273ac133ecdce598fc1f4140a08a1bf2044048bff8106371d207d105f/yarl-1.24.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:96d30286dd02679e32a39aa8f0b7498fc847fcda46cfc09df5513e82ce252440", size = 117413, upload-time = "2026-07-20T02:04:41.549Z" }, + { url = "https://files.pythonhosted.org/packages/a4/55/9307e03977d3b290dfa42e5d2bae7b6140808fd1786fbe70cd9d3bee53c5/yarl-1.24.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:fd8c81f346b58f45818d09ea11db69a8d5fd34a224b79871f6d44f12cd7977b1", size = 109498, upload-time = "2026-07-20T02:04:43.468Z" }, + { url = "https://files.pythonhosted.org/packages/fc/be/791a6f314cb4c989c19f8e3a10271f1e469c077143915e52474d80f26b4b/yarl-1.24.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:5c55256dee8f4b27bfbf636c8363383c7c8db7890c7cba5217d7bd5f5f21dab6", size = 116062, upload-time = "2026-07-20T02:04:45.319Z" }, + { url = "https://files.pythonhosted.org/packages/19/1a/ddd3807b86055010e2f99aa89b3c640effdb65696766c20597f696f48a1c/yarl-1.24.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9f4d8cf085a4c6a40fb97ea0f46938a8df43c85d31f9d45e2a8867ea9293790d", size = 110941, upload-time = "2026-07-20T02:04:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/6d/03/f34271bba042d2187508bf62aea20a14129efb5a1acfc6a2efe7544630b4/yarl-1.24.5-cp310-cp310-win_amd64.whl", hash = "sha256:240cbec09667c1fed4c6cd0060b9ec57332427d7441289a2ed8875dc9fb2b224", size = 97534, upload-time = "2026-07-20T02:04:48.774Z" }, + { url = "https://files.pythonhosted.org/packages/e4/02/ecc8dc31b9f355731e700f8402b8075d2ea1737dbc4baf4abf0f0fc64288/yarl-1.24.5-cp310-cp310-win_arm64.whl", hash = "sha256:8a6987eaad834cb32dd57d9d582225f0054a5d1af706ccfbbdba735af4927e13", size = 93603, upload-time = "2026-07-20T02:04:50.686Z" }, + { url = "https://files.pythonhosted.org/packages/fe/db/3cb5df059756a45761cc3dee8fd25ec82b83a6585ea3542b969fda850f99/yarl-1.24.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2c1fe720934a16ea8e7146175cba2126f87f54912c8c5435e7f7c7a51ef808d3", size = 135043, upload-time = "2026-07-20T02:04:52.39Z" }, + { url = "https://files.pythonhosted.org/packages/44/f8/767d6bd5a03db63bc467df2fb56d6fafeae9667d74aea92cd6af399f828b/yarl-1.24.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c687ed078e145f5fd53a14854beff320e1d2ab76df03e2009c98f39a0f68f39a", size = 96942, upload-time = "2026-07-20T02:04:54.26Z" }, + { url = "https://files.pythonhosted.org/packages/ce/97/10b939c44d7b28d1dbc389cfc7012306d1ea8dba01eaef44b39fffaee52a/yarl-1.24.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:709f1efed56c4a145793c046cd4939f9959bcd818979a787b77d8e09c57a0840", size = 97046, upload-time = "2026-07-20T02:04:56.638Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7a/b410dbe39b6255c55fb2a2bcee96eb844d0789235ddc381a889a90dc72d6/yarl-1.24.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:874019bd513008b009f58657134e5d0c5e030b3559bd0553976837adf52fe966", size = 110512, upload-time = "2026-07-20T02:04:58.955Z" }, + { url = "https://files.pythonhosted.org/packages/83/c7/da591971f78a5617e1f21f5699858ebccd836fe181a6493788ffc91ba69b/yarl-1.24.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a4582acf7ef76482f6f511ebaf1946dae7f2e85ec4728b81a678c01df63bd723", size = 102454, upload-time = "2026-07-20T02:05:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8e/73b0ed4de47289a78a96045d76d1cfe5e41848bf0da59ce25b2ec87ee05d/yarl-1.24.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2cabe6546e41dabe439999a23fcb5246e0c3b595b4315b96ef755252be90caeb", size = 117617, upload-time = "2026-07-20T02:05:02.325Z" }, + { url = "https://files.pythonhosted.org/packages/cf/14/b744747bc4f57a8d55bd744df463457524583e1e9f7538b5ace0346ab92e/yarl-1.24.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:17f57620f5475b3c69109376cc87e42a7af5db13c9398e4292772a706ff10780", size = 116135, upload-time = "2026-07-20T02:05:04.05Z" }, + { url = "https://files.pythonhosted.org/packages/66/ca/95aa4d0e5b7ea4f20e4d577c42d001ed9df207569fdb063cc5ed4ebb496b/yarl-1.24.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:570fec8fbd22b032733625f03f10b7ff023bc399213db15e72a7acaef28c2f4e", size = 111935, upload-time = "2026-07-20T02:05:05.738Z" }, + { url = "https://files.pythonhosted.org/packages/72/0d/d2ad8d6b147832d177a4e720ba1962fe686eb0913b74503b3eca094b8bba/yarl-1.24.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5fede79c6f73ff2c3ef822864cb1ada23196e62756df53bc6231d351a49516a2", size = 110010, upload-time = "2026-07-20T02:05:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/50/18/eb335e4120903903f4865041355ae46256a2406eb2865bc24827f4f27b61/yarl-1.24.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ccf9aca873b767977c73df497a85dbedee4ee086ae9ae49dc461333b9b79f58", size = 110058, upload-time = "2026-07-20T02:05:09.246Z" }, + { url = "https://files.pythonhosted.org/packages/44/70/97353add32c62ad6f206d948ac5a5ee84398225e534dc6ed6433d1b335b6/yarl-1.24.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ad5d8201d310b031e6cd839d9bac2d4e5a01533ce5d3d5b50b7de1ef3af1de61", size = 103308, upload-time = "2026-07-20T02:05:11.31Z" }, + { url = "https://files.pythonhosted.org/packages/68/39/5e7398d4b6f6b3c9062823ebc60802df5b272e3fe9e788f9734c6ee46c85/yarl-1.24.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:841f0852f48fefea3b12c9dfec00704dfa3aef5215d0e3ce564bb3d7cd8d57c6", size = 116898, upload-time = "2026-07-20T02:05:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c9/09e52f2239e8b96357eccca05915382e4ba5405ebfb623b6036040d99654/yarl-1.24.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:9baafc71b04f8f4bb0703b21d6fc9f0c30b346c636a532ff16ec8491a5ea4b1f", size = 109400, upload-time = "2026-07-20T02:05:14.821Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6a/e94133d4c2d1a14d2384310bf3e79d9cf32c9d1eae1c6f034fb80d098fa1/yarl-1.24.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d897129df1a22b12aeed2c2c98df0785a2e8e6e0bde87b389491d0025c187077", size = 115934, upload-time = "2026-07-20T02:05:17.78Z" }, + { url = "https://files.pythonhosted.org/packages/4e/3c/34955ed967b976fc38edcbb6d538dee79dbda4cb7fc7f72a0907a7c78e0f/yarl-1.24.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dd625535328fd9882374356269227670189adfcc6a2d90284f323c05862eecbd", size = 112178, upload-time = "2026-07-20T02:05:19.675Z" }, + { url = "https://files.pythonhosted.org/packages/f5/46/d7bd3a8859d47dcfaffd7127af7076032a7da278a9a02e17b5f37bfb6712/yarl-1.24.5-cp311-cp311-win_amd64.whl", hash = "sha256:f4239bbec5a3577ddb49e4b50aeb32d8e5792098262ae2f63723f916a29b1a25", size = 97544, upload-time = "2026-07-20T02:05:21.523Z" }, + { url = "https://files.pythonhosted.org/packages/01/69/c1bfd21e32c638974ea2c542a0b8c53ef1fa9eff336020f5d014f9503ff2/yarl-1.24.5-cp311-cp311-win_arm64.whl", hash = "sha256:3ac6aff147deb9c09461b2d4bbdf6256831198f5d8a23f5d37138213090b6d8a", size = 93359, upload-time = "2026-07-20T02:05:23.493Z" }, + { url = "https://files.pythonhosted.org/packages/1b/84/71d051c850b5af41d168c679d9eb67eb7c55283ac4ee131673edf134bc4e/yarl-1.24.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d693396e5aea78db03decd60aec9ece16c9b40ba00a587f089615ff4e718a81d", size = 136035, upload-time = "2026-07-20T02:05:25.489Z" }, + { url = "https://files.pythonhosted.org/packages/03/4d/8ad27f9a1b7e69313cca5d695b925b48efe51208d3490e0844bae97cabc0/yarl-1.24.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3363fcc96e665878946ad7a106b9a13eac0541766a690ef287c0232ac768b6ec", size = 97642, upload-time = "2026-07-20T02:05:27.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b4/05b4131c407006cd1e410e9c6539f16a0945724677e5364447313c15ea3e/yarl-1.24.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c", size = 97323, upload-time = "2026-07-20T02:05:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/20/16/e618c875c73e0e39611f20a581b3d5e8d59b8857bf001bee3263044c6deb/yarl-1.24.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90333fd89b43c0d08ac85f3f1447593fc2c66de18c3d6378d7125ea118dc7a54", size = 107741, upload-time = "2026-07-20T02:05:31.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/c4defeaf3ed33fcb346aacf9c6e971a8d4e2bde04a0310e79abb208e7965/yarl-1.24.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:665b0a2c463cc9423dd647e0bfd9f4ccc9b50f768c55304d5e9f80b177c1de12", size = 103570, upload-time = "2026-07-20T02:05:33.303Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e7/0e0e0de5865ebd5914537ef486f36c727a59865c3ac0cf5ff1b32aececbf/yarl-1.24.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e006d3a974c4ee19512e5f058abedb6eef36a5e553c14812bdeba1758d812e6d", size = 115815, upload-time = "2026-07-20T02:05:35.292Z" }, + { url = "https://files.pythonhosted.org/packages/2b/27/ca56b700cb170aba25a3893b75355b213935657dc5714d2383354a270e62/yarl-1.24.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7d42c531243450ef0d4d9c172e7ed6ef052640f195629065041b5add4e058d1", size = 116025, upload-time = "2026-07-20T02:05:37.503Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d0/d56c859b8222116f5d68459199f48359e0bf121b6f65a69bf329b3602ba0/yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9", size = 109835, upload-time = "2026-07-20T02:05:39.506Z" }, + { url = "https://files.pythonhosted.org/packages/70/a2/3a35557e4d1a79425040eba202ccaf08bdc8717680fc77e2498a1ad2e0a5/yarl-1.24.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95b17fe34ed802f17e205112e6e10db92275c34fee290aa9bdc55a9c724027", size = 108884, upload-time = "2026-07-20T02:05:41.584Z" }, + { url = "https://files.pythonhosted.org/packages/e4/35/ef4c26356b7913c68983bac2d72a4212b3347af551cb8d250b99b5ed7b7f/yarl-1.24.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:56b149b22de33b23b0c6077ab9518c6dcb538ad462e1830e68d06591ccf6e38b", size = 107308, upload-time = "2026-07-20T02:05:43.697Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/ff0dc66c2ccf3e0153ab97ff61eabab4400e6a5264af427ab30cd69f1857/yarl-1.24.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a8fe66b8f300da93798025a785a5b90b42f3810dc2b72283ff84a41aaaebc293", size = 103646, upload-time = "2026-07-20T02:05:45.895Z" }, + { url = "https://files.pythonhosted.org/packages/74/f0/33b9271c7f881766359d58266fa0811d2e5210ed860e28da7dc6d7786344/yarl-1.24.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:377fe3732edbaf78ee74efdf2c9f49f6e99f20e7f9d2649fda3eb4badd77d76e", size = 115305, upload-time = "2026-07-20T02:05:47.832Z" }, + { url = "https://files.pythonhosted.org/packages/ef/65/fd79fb1868c4a80db8661091de525bf430f63c3bea1b20e8b6a84fc7d359/yarl-1.24.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e8ffa78582120024f476a611d7befc123cee59e47e8309d470cf667d806e613b", size = 108404, upload-time = "2026-07-20T02:05:49.604Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ba/dbabe6b262f17a816c70cfc09558dbf03ece3ec76684d02f911a3d3a189c/yarl-1.24.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:daba5e594f06114e37db186efd2dd916609071e59daca901a0a2e71f02b142ce", size = 115940, upload-time = "2026-07-20T02:05:51.741Z" }, + { url = "https://files.pythonhosted.org/packages/a5/43/fab2d1dad9d340a268cdde63756a123d069723efff6a372d123fa74a9517/yarl-1.24.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65be18ec59496c13908f02a2472751d9ef840b4f3fb5726f129306bf6a2a7bba", size = 110006, upload-time = "2026-07-20T02:05:53.554Z" }, + { url = "https://files.pythonhosted.org/packages/c4/27/41eb51bbd1b8d89546b83897cfb0164f1e109304fd408dbb151b639eec0f/yarl-1.24.5-cp312-cp312-win_amd64.whl", hash = "sha256:a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b", size = 97618, upload-time = "2026-07-20T02:05:55.57Z" }, + { url = "https://files.pythonhosted.org/packages/3c/25/b2553764b3d65db711d8f45416351ec4f420847558eb669edcbcaadf5780/yarl-1.24.5-cp312-cp312-win_arm64.whl", hash = "sha256:7ce27823052e2013b597e0c738b13e7e36b8ccb9400df8959417b052ab0fd92c", size = 93018, upload-time = "2026-07-20T02:05:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/e1/63/64ef361967cc983573149dc1515d531db5da8a4c92d22bb833d59e01b313/yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2", size = 135075, upload-time = "2026-07-20T02:05:59.671Z" }, + { url = "https://files.pythonhosted.org/packages/bb/89/55920fd853ce43e608adbc3962456f0d649d6bb15250dc2988321da0fe1c/yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb", size = 97225, upload-time = "2026-07-20T02:06:01.769Z" }, + { url = "https://files.pythonhosted.org/packages/15/f0/7688d3f2cfff7590df2af38ec46d969f4281a4dddb08a9ad2eafbcdddf98/yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075", size = 96751, upload-time = "2026-07-20T02:06:03.676Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/a851a0f94aaaf379dd4f901bfc80f634280bec51eb260b47363e2a4cd62e/yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff", size = 107960, upload-time = "2026-07-20T02:06:05.699Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a8/faea066c12f9c77ca0de90641f1655f9dd7b412477bf28c76d692f3aecff/yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448", size = 103500, upload-time = "2026-07-20T02:06:07.556Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/1e67084c2a6e2f2db0e3be798328cb3be42c0119b621d25461479a224d21/yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f", size = 115780, upload-time = "2026-07-20T02:06:09.599Z" }, + { url = "https://files.pythonhosted.org/packages/58/86/1f94664e147474337e3359f52012cf3d02f825f694317b178bfba1078c62/yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd", size = 115308, upload-time = "2026-07-20T02:06:11.352Z" }, + { url = "https://files.pythonhosted.org/packages/0a/43/8e55ae7538ba5f28ccb3c845c6dd4549cf7016d5992e5326512519107cdd/yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16", size = 110574, upload-time = "2026-07-20T02:06:13.129Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ba/a889ec8765cedcf2ac44dcb02d6a21e4861399b243b263c5f2dde27ee740/yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213", size = 109914, upload-time = "2026-07-20T02:06:15.243Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c3/e45f821af67b791c2dbbe4a9f4137a1d33f8d386654a05a0c3f47bdfa25d/yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24", size = 107712, upload-time = "2026-07-20T02:06:17.443Z" }, + { url = "https://files.pythonhosted.org/packages/02/00/2ab0f42c9857fcb490bfaa6647b14540b53d241ab209f23220b958cc5832/yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385", size = 104251, upload-time = "2026-07-20T02:06:19.259Z" }, + { url = "https://files.pythonhosted.org/packages/7a/70/709d9a286e98af2c7fd8e4e6cada658b5c0e30d87dd7e2a63c2fb5767217/yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c", size = 115319, upload-time = "2026-07-20T02:06:21.207Z" }, + { url = "https://files.pythonhosted.org/packages/5c/6c/3eaa515142991fe84cfc483ff986492211f1978f90161ccefdbec919d09b/yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4", size = 109163, upload-time = "2026-07-20T02:06:23.006Z" }, + { url = "https://files.pythonhosted.org/packages/bb/64/711dafce66c323a3144d470547a71c5384c57623308ac8bb5e4b903ac148/yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144", size = 115435, upload-time = "2026-07-20T02:06:24.923Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f3/9b9d0e6d84bea851eb1ba99e4bdc755b86fd813e49ec86dfe42f26befdef/yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4", size = 110691, upload-time = "2026-07-20T02:06:26.973Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/62a06b7e87c4246ac76b7c2da136f972eb4a3a1fc94abb07e7022d6fdb0a/yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740", size = 97454, upload-time = "2026-07-20T02:06:29.163Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c9/5fc8025b318ab10db413b61056bd0d95c557a70e8df4210c7511f866329c/yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1", size = 92813, upload-time = "2026-07-20T02:06:31.113Z" }, + { url = "https://files.pythonhosted.org/packages/a9/08/5f3085fef9564217074db9dd8573de1795bc82cde61a7ad10b6a7234a569/yarl-1.24.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76", size = 135680, upload-time = "2026-07-20T02:06:33.273Z" }, + { url = "https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d", size = 97395, upload-time = "2026-07-20T02:06:35.259Z" }, + { url = "https://files.pythonhosted.org/packages/18/a9/a07f76f3c44e02b25cc743af5ef93eef27f7013eadca770451b6a6ccb5db/yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75", size = 97223, upload-time = "2026-07-20T02:06:37.216Z" }, + { url = "https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9", size = 108777, upload-time = "2026-07-20T02:06:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/2f/44/e0b86c302471fabd6f02808ecf2ac52b8412b624787849d4bf2cdb466f6f/yarl-1.24.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede", size = 103119, upload-time = "2026-07-20T02:06:41.456Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/9c16d180bf8faaf223225eb50e1245870ff1ae0e302a27153988e65c51fd/yarl-1.24.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca", size = 116471, upload-time = "2026-07-20T02:06:43.696Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8d/b219b9df28a02ce95cfbdd41d2f7caa5669d0ff979c1c9975697145e33c5/yarl-1.24.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027", size = 115974, upload-time = "2026-07-20T02:06:45.874Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9", size = 110830, upload-time = "2026-07-20T02:06:47.852Z" }, + { url = "https://files.pythonhosted.org/packages/db/18/199b85109a53eeca64ee19c9cca228287e8e4ab0cc1a09b28f530e65cce0/yarl-1.24.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41", size = 110054, upload-time = "2026-07-20T02:06:49.84Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/ed28147f8cd7f48c49367c90713b30a555284b6105a6a56f3a05568da795/yarl-1.24.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373", size = 108312, upload-time = "2026-07-20T02:06:51.835Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c5/55e16ae0a5c227cea8df1c6871ba57d614a34243146c05729caf2a1bd9c5/yarl-1.24.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36", size = 103662, upload-time = "2026-07-20T02:06:54.061Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ea/dbd7c2caec459c9a426f18b02688ecbfb58620d0f6a3422d24769fbaf8ab/yarl-1.24.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0", size = 116090, upload-time = "2026-07-20T02:06:56.015Z" }, + { url = "https://files.pythonhosted.org/packages/06/84/39ce4ce3059e07fece5fbdbee8c4053406af9aca911ce9fa5f8548aab6af/yarl-1.24.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5", size = 109523, upload-time = "2026-07-20T02:06:57.926Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8b/71ff44137b405c64a7788075669c24010019f57a7464b78c3a6cbee539d9/yarl-1.24.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5", size = 116084, upload-time = "2026-07-20T02:06:59.868Z" }, + { url = "https://files.pythonhosted.org/packages/62/c0/423078fdd4042e1862c11f0ffd977a0ffa393783c12bee94685923bc189e/yarl-1.24.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4", size = 111006, upload-time = "2026-07-20T02:07:01.907Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad", size = 99215, upload-time = "2026-07-20T02:07:03.852Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0e/464a847d7359e0da75dd9fc5c1d1aa35d0159ea31e5f8e66a3c1c29ff3d0/yarl-1.24.5-cp314-cp314-win_arm64.whl", hash = "sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f", size = 94566, upload-time = "2026-07-20T02:07:06.074Z" }, + { url = "https://files.pythonhosted.org/packages/e2/55/e03acc4446772660bc335e86e41ef31e4d0d838fd641531a11a5ee33b493/yarl-1.24.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88", size = 142533, upload-time = "2026-07-20T02:07:08.284Z" }, + { url = "https://files.pythonhosted.org/packages/ae/71/4acd3a1fc7cf14345cdb302665ecd2097f62c365b4f14ca17d4f37775cf9/yarl-1.24.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba", size = 100776, upload-time = "2026-07-20T02:07:10.197Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/cfb76b7fe99686db264bff829779a539d923e7564ffd7ef18da6c54c3774/yarl-1.24.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928", size = 100913, upload-time = "2026-07-20T02:07:12.357Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3f/7116e782992abbd4fb6948488aec72078895e929a23078290739e8396fce/yarl-1.24.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f", size = 106507, upload-time = "2026-07-20T02:07:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/33/90/d4d2d73ee78229cc889872eb8e085d8f5c6f51abdb178409fd9b23cf74fd/yarl-1.24.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95", size = 99219, upload-time = "2026-07-20T02:07:16.019Z" }, + { url = "https://files.pythonhosted.org/packages/3e/fa/a6df1a9bccd644eec00abee0dff4277416222cec435330fd1f2858523ec1/yarl-1.24.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc", size = 111804, upload-time = "2026-07-20T02:07:18.141Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/7b2a1f4bcc20e9447156dd2b1c4d01f70d9df0759025ee7d09a84ffae134/yarl-1.24.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da", size = 110943, upload-time = "2026-07-20T02:07:20.06Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/22c92affb0f9b623ca753d27d968b5625b868f12c6378d049d55ae247643/yarl-1.24.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a", size = 108251, upload-time = "2026-07-20T02:07:22.217Z" }, + { url = "https://files.pythonhosted.org/packages/45/44/5769b96298c1e195fb412997b6090af2a84105cf59c17613558a2d011d1f/yarl-1.24.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0", size = 106025, upload-time = "2026-07-20T02:07:24.083Z" }, + { url = "https://files.pythonhosted.org/packages/4c/40/009e8e791fd9762c0e1567e69248acb4f49064597e1680874c16dd8bb798/yarl-1.24.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498", size = 106573, upload-time = "2026-07-20T02:07:26.248Z" }, + { url = "https://files.pythonhosted.org/packages/20/c6/b7480578f8a0a80946f36ad6df547ecec704f9ba69d2de60f8aa6f1c1cbf/yarl-1.24.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104", size = 100751, upload-time = "2026-07-20T02:07:28.098Z" }, + { url = "https://files.pythonhosted.org/packages/d4/27/4476f3360b91a48c5cf125e91f59a3bd35299d84a431a258d57f5977bb11/yarl-1.24.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331", size = 111643, upload-time = "2026-07-20T02:07:30.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4b/5cdd3e5ee944e8af31e52f6cd3d3af5fd7b937e036ccbbba2c9ffebede95/yarl-1.24.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550", size = 106312, upload-time = "2026-07-20T02:07:33.06Z" }, + { url = "https://files.pythonhosted.org/packages/18/86/f406b0c2a6f99575de2da671ef47aa06f89a5be83a27a46971c3b86cecdb/yarl-1.24.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6", size = 110379, upload-time = "2026-07-20T02:07:35.155Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6c/9f3adfbd3b30b4fa0f7ccb3a83eba2c1152d3fff554d535e640ba0f7ba2b/yarl-1.24.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047", size = 108497, upload-time = "2026-07-20T02:07:37.35Z" }, + { url = "https://files.pythonhosted.org/packages/dd/37/91eb2e5ca883a529c1b390348a74cd9fc0512171727f547ce70bfe02be5c/yarl-1.24.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104", size = 102450, upload-time = "2026-07-20T02:07:39.578Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f4/ed5c402ac8fde4403ed3366c2716bfddc8a6677ebd59f3d62772cc7fe468/yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688", size = 97222, upload-time = "2026-07-20T02:07:41.55Z" }, + { url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" }, +] + +[[package]] +name = "zope-event" +version = "6.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9.12' and python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.9.12'", + "python_full_version <= '3.9'", +] +dependencies = [ + { name = "setuptools", version = "82.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/d8/9c8b0c6bb1db09725395618f68d3b8a08089fca0aed28437500caaf713ee/zope_event-6.0.tar.gz", hash = "sha256:0ebac894fa7c5f8b7a89141c272133d8c1de6ddc75ea4b1f327f00d1f890df92", size = 18731, upload-time = "2025-09-12T07:10:13.551Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/b5/1abb5a8b443314c978617bf46d5d9ad648bdf21058074e817d7efbb257db/zope_event-6.0-py3-none-any.whl", hash = "sha256:6f0922593407cc673e7d8766b492c519f91bdc99f3080fe43dcec0a800d682a3", size = 6409, upload-time = "2025-09-12T07:10:12.316Z" }, +] + +[[package]] +name = "zope-event" +version = "6.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/93/41/faa10af34d48d9cd6fa0249a1162943ad84a9590bd1a06939981e6640416/zope_event-6.2.tar.gz", hash = "sha256:b97d5d6327067ee6b9dfcbdf606ade9ade70991e19c162e808ea39e5fcf0f8d3", size = 18958, upload-time = "2026-04-28T06:24:10.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/33/848922889e946d4befc415c219fe516af75c49555d8e736e183bfd30db42/zope_event-6.2-py3-none-any.whl", hash = "sha256:5e755153ac4faf64c10a4b6dd3307680166a3edf65b38df22df592610f8fa874", size = 6525, upload-time = "2026-04-28T06:24:09.176Z" }, +] + +[[package]] +name = "zope-interface" +version = "8.0.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9.12' and python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.9.12'", + "python_full_version <= '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/88/3a/7fcf02178b8fad0a51e67e32765cd039ae505d054d744d76b8c2bbcba5ba/zope_interface-8.0.1.tar.gz", hash = "sha256:eba5610d042c3704a48222f7f7c6ab5b243ed26f917e2bc69379456b115e02d1", size = 253746, upload-time = "2025-09-25T05:55:51.285Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/e5/ffef169d17b92c6236b3b18b890c0ce73502f3cbd5b6532ff20d412d94a3/zope_interface-8.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fd7195081b8637eeed8d73e4d183b07199a1dc738fb28b3de6666b1b55662570", size = 207364, upload-time = "2025-09-25T05:58:50.262Z" }, + { url = "https://files.pythonhosted.org/packages/35/b6/87aca626c09af829d3a32011599d6e18864bc8daa0ad3a7e258f3d7f8bcf/zope_interface-8.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f7c4bc4021108847bce763673ce70d0716b08dfc2ba9889e7bad46ac2b3bb924", size = 207901, upload-time = "2025-09-25T05:58:51.74Z" }, + { url = "https://files.pythonhosted.org/packages/d8/c1/eec33cc9f847ebeb0bc6234d7d45fe3fc0a6fe8fc5b5e6be0442bd2c684d/zope_interface-8.0.1-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:758803806b962f32c87b31bb18c298b022965ba34fe532163831cc39118c24ab", size = 249358, upload-time = "2025-09-25T05:58:16.979Z" }, + { url = "https://files.pythonhosted.org/packages/58/7d/1e3476a1ef0175559bd8492dc7bb921ad0df5b73861d764b1f824ad5484a/zope_interface-8.0.1-cp310-cp310-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f8e88f35f86bbe8243cad4b2972deef0fdfca0a0723455abbebdc83bbab96b69", size = 254475, upload-time = "2025-09-25T05:58:10.032Z" }, + { url = "https://files.pythonhosted.org/packages/bc/67/ba5ea98ff23f723c5cbe7db7409f2e43c9fe2df1ced67881443c01e64478/zope_interface-8.0.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7844765695937d9b0d83211220b72e2cf6ac81a08608ad2b58f2c094af498d83", size = 254913, upload-time = "2025-09-25T06:26:22.263Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a7/b1b8b6c13fba955c043cdee409953ee85f652b106493e2e931a84f95c1aa/zope_interface-8.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:64fa7b206dd9669f29d5c1241a768bebe8ab1e8a4b63ee16491f041e058c09d0", size = 211753, upload-time = "2025-09-25T05:59:00.561Z" }, + { url = "https://files.pythonhosted.org/packages/f2/2f/c10c739bcb9b072090c97c2e08533777497190daa19d190d72b4cce9c7cb/zope_interface-8.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4bd01022d2e1bce4a4a4ed9549edb25393c92e607d7daa6deff843f1f68b479d", size = 207903, upload-time = "2025-09-25T05:58:21.671Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e1/9845ac3697f108d9a1af6912170c59a23732090bbfb35955fe77e5544955/zope_interface-8.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:29be8db8b712d94f1c05e24ea230a879271d787205ba1c9a6100d1d81f06c69a", size = 208345, upload-time = "2025-09-25T05:58:24.217Z" }, + { url = "https://files.pythonhosted.org/packages/f2/49/6573bc8b841cfab18e80c8e8259f1abdbbf716140011370de30231be79ad/zope_interface-8.0.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:51ae1b856565b30455b7879fdf0a56a88763b401d3f814fa9f9542d7410dbd7e", size = 255027, upload-time = "2025-09-25T05:58:19.975Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fd/908b0fd4b1ab6e412dfac9bd2b606f2893ef9ba3dd36d643f5e5b94c57b3/zope_interface-8.0.1-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d2e7596149cb1acd1d4d41b9f8fe2ffc0e9e29e2e91d026311814181d0d9efaf", size = 259800, upload-time = "2025-09-25T05:58:11.487Z" }, + { url = "https://files.pythonhosted.org/packages/dc/78/8419a2b4e88410520ed4b7f93bbd25a6d4ae66c4e2b131320f2b90f43077/zope_interface-8.0.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b2737c11c34fb9128816759864752d007ec4f987b571c934c30723ed881a7a4f", size = 260978, upload-time = "2025-09-25T06:26:24.483Z" }, + { url = "https://files.pythonhosted.org/packages/e5/90/caf68152c292f1810e2bd3acd2177badf08a740aa8a348714617d6c9ad0b/zope_interface-8.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:cf66e4bf731aa7e0ced855bb3670e8cda772f6515a475c6a107bad5cb6604103", size = 212155, upload-time = "2025-09-25T05:59:40.318Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a6/0f08713ddda834c428ebf97b2a7fd8dea50c0100065a8955924dbd94dae8/zope_interface-8.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:115f27c1cc95ce7a517d960ef381beedb0a7ce9489645e80b9ab3cbf8a78799c", size = 208609, upload-time = "2025-09-25T05:58:53.698Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5e/d423045f54dc81e0991ec655041e7a0eccf6b2642535839dd364b35f4d7f/zope_interface-8.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af655c573b84e3cb6a4f6fd3fbe04e4dc91c63c6b6f99019b3713ef964e589bc", size = 208797, upload-time = "2025-09-25T05:58:56.258Z" }, + { url = "https://files.pythonhosted.org/packages/c6/43/39d4bb3f7a80ebd261446792493cfa4e198badd47107224f5b6fe1997ad9/zope_interface-8.0.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:23f82ef9b2d5370750cc1bf883c3b94c33d098ce08557922a3fbc7ff3b63dfe1", size = 259242, upload-time = "2025-09-25T05:58:21.602Z" }, + { url = "https://files.pythonhosted.org/packages/da/29/49effcff64ef30731e35520a152a9dfcafec86cf114b4c2aff942e8264ba/zope_interface-8.0.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35a1565d5244997f2e629c5c68715b3d9d9036e8df23c4068b08d9316dcb2822", size = 264696, upload-time = "2025-09-25T05:58:13.351Z" }, + { url = "https://files.pythonhosted.org/packages/c7/39/b947673ec9a258eeaa20208dd2f6127d9fbb3e5071272a674ebe02063a78/zope_interface-8.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:029ea1db7e855a475bf88d9910baab4e94d007a054810e9007ac037a91c67c6f", size = 264229, upload-time = "2025-09-25T06:26:26.226Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ee/eed6efd1fc3788d1bef7a814e0592d8173b7fe601c699b935009df035fc2/zope_interface-8.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0beb3e7f7dc153944076fcaf717a935f68d39efa9fce96ec97bafcc0c2ea6cab", size = 212270, upload-time = "2025-09-25T05:58:53.584Z" }, + { url = "https://files.pythonhosted.org/packages/5f/dc/3c12fca01c910c793d636ffe9c0984e0646abaf804e44552070228ed0ede/zope_interface-8.0.1-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:c7cc027fc5c61c5d69e5080c30b66382f454f43dc379c463a38e78a9c6bab71a", size = 208992, upload-time = "2025-09-25T05:58:40.712Z" }, + { url = "https://files.pythonhosted.org/packages/46/71/6127b7282a3e380ca927ab2b40778a9c97935a4a57a2656dadc312db5f30/zope_interface-8.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fcf9097ff3003b7662299f1c25145e15260ec2a27f9a9e69461a585d79ca8552", size = 209051, upload-time = "2025-09-25T05:58:42.182Z" }, + { url = "https://files.pythonhosted.org/packages/56/86/4387a9f951ee18b0e41fda77da77d59c33e59f04660578e2bad688703e64/zope_interface-8.0.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6d965347dd1fb9e9a53aa852d4ded46b41ca670d517fd54e733a6b6a4d0561c2", size = 259223, upload-time = "2025-09-25T05:58:23.191Z" }, + { url = "https://files.pythonhosted.org/packages/61/08/ce60a114466abc067c68ed41e2550c655f551468ae17b4b17ea360090146/zope_interface-8.0.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9a3b8bb77a4b89427a87d1e9eb969ab05e38e6b4a338a9de10f6df23c33ec3c2", size = 264690, upload-time = "2025-09-25T05:58:15.052Z" }, + { url = "https://files.pythonhosted.org/packages/36/9a/62a9ba3a919594605a07c34eee3068659bbd648e2fa0c4a86d876810b674/zope_interface-8.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:87e6b089002c43231fb9afec89268391bcc7a3b66e76e269ffde19a8112fb8d5", size = 264201, upload-time = "2025-09-25T06:26:27.797Z" }, + { url = "https://files.pythonhosted.org/packages/da/06/8fe88bd7edef60566d21ef5caca1034e10f6b87441ea85de4bbf9ea74768/zope_interface-8.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:64a43f5280aa770cbafd0307cb3d1ff430e2a1001774e8ceb40787abe4bb6658", size = 212273, upload-time = "2025-09-25T06:00:25.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/24/d5c5e7936e014276b7a98a076de4f5dc2587100fea95779c1e36650b8770/zope_interface-8.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b84464a9fcf801289fa8b15bfc0829e7855d47fb4a8059555effc6f2d1d9a613", size = 207443, upload-time = "2025-09-25T05:59:34.299Z" }, + { url = "https://files.pythonhosted.org/packages/8a/76/565cf6db478ba344b27cfd6828f17da2888cf1beb521bce31142b3041fb5/zope_interface-8.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7b915cf7e747b5356d741be79a153aa9107e8923bc93bcd65fc873caf0fb5c50", size = 207928, upload-time = "2025-09-25T05:59:35.524Z" }, + { url = "https://files.pythonhosted.org/packages/65/03/9780355205c3b3f55e9ce700e52846b40d0bab99c078e102c0d7e2f3e022/zope_interface-8.0.1-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:110c73ddf974b369ef3c6e7b0d87d44673cf4914eba3fe8a33bfb21c6c606ad8", size = 248605, upload-time = "2025-09-25T05:58:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/44/09/b10eda92f1373cd8e4e9dd376559414d1759a8b54e98eeef0d81844f0638/zope_interface-8.0.1-cp39-cp39-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9e9bdca901c1bcc34e438001718512c65b3b8924aabcd732b6e7a7f0cd715f17", size = 253793, upload-time = "2025-09-25T05:58:16.92Z" }, + { url = "https://files.pythonhosted.org/packages/62/37/3529065a2b6b7dc8f287ff3c8d1e8b7d8c4681c069e520bd3c4ac995a95c/zope_interface-8.0.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bbd22d4801ad3e8ec704ba9e3e6a4ac2e875e4d77e363051ccb76153d24c5519", size = 254263, upload-time = "2025-09-25T06:26:29.371Z" }, + { url = "https://files.pythonhosted.org/packages/c3/31/42588bea7ddad3abd2d4987ec3b767bd09394cc091a4918b1cf7b9de07ae/zope_interface-8.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:a0016ca85f93b938824e2f9a43534446e95134a2945b084944786e1ace2020bc", size = 211780, upload-time = "2025-09-25T05:59:50.015Z" }, +] + +[[package]] +name = "zope-interface" +version = "8.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/08/dc/50550cfcbb2ea3cbca5f1d7ed05c8aa840f831a0f2d63aec0a953f7c590e/zope_interface-8.5.tar.gz", hash = "sha256:7a3ba1c5877f0f3e3906b02ddf793abed2becc2948116414ce0e1dd820b68d6d", size = 257957, upload-time = "2026-05-26T06:50:14.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/43/9cd98bee951d23848de690ba2809f87e3b22c67c370987acc960da15ad37/zope_interface-8.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c0c8aa2bf8f3911ef37b87deb1bbe225a310e6eb6522a16d77f5d8330c4f6fbe", size = 210951, upload-time = "2026-05-26T06:49:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/17/0f/8f1a29966bcf863e3a2121edcafb81c55715de7886bcc9544749cc79e7da/zope_interface-8.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:efe234a0fafb4b6b1602e9be9245b97c2bf06d67c07af5a4bc3c0438978b555c", size = 211309, upload-time = "2026-05-26T06:49:02.732Z" }, + { url = "https://files.pythonhosted.org/packages/9f/9f/37e564eaaf85e3abc1ada40a79fa43f2ab45bdb67431b0ec0fe29e4763e2/zope_interface-8.5-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:dabeb6fe1228d411994f300811edc6866fff0cdcbc9cef98a78f05ea0da42e37", size = 254881, upload-time = "2026-05-26T06:49:04.303Z" }, + { url = "https://files.pythonhosted.org/packages/06/61/e6501d8ea7a2cac3217e03f404e1f98c1df7191d83cfe86b1895fbba5dac/zope_interface-8.5-cp310-cp310-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:147a9442dcc2b7339ecdb1be2b3cdb098e90462e39425054053ebfb50d99125a", size = 259811, upload-time = "2026-05-26T06:49:06.373Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/bfa25ef480b02af6e9452c478483fec75e87c9e2b60c407fd0b1f6054b9c/zope_interface-8.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a17e681224267880707c9ec9e730ad9a1ad2d65c371256843efba6cf48711b58", size = 260358, upload-time = "2026-05-26T06:49:08.317Z" }, + { url = "https://files.pythonhosted.org/packages/64/51/2b518072fea76242da64451d501c69b7b5ccdef9b57fead584ccf1c180d5/zope_interface-8.5-cp310-cp310-win_amd64.whl", hash = "sha256:d178968a1a611df30549a717d1624cb38ca810347339e3e37b7baa6f6781a170", size = 214822, upload-time = "2026-05-26T06:49:10.441Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f1/83ad110fb847413affe71609bb50e59e1aa082e1236030122227c7c283d3/zope_interface-8.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:afc66ccaef2a3c0bef6ca02aad40d29a39276389dad16a8eac36f9f385e4d057", size = 211426, upload-time = "2026-05-26T06:49:12.595Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a7/6b6e0c31ac240cb9fc015ae9ed45ca54be886c18fcf7bfa2377a4d7a8785/zope_interface-8.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c28044972187245d7a309e4699319bfdbd2ffcbf7176d1d4ddf5adffb2dea80f", size = 211850, upload-time = "2026-05-26T06:49:14.474Z" }, + { url = "https://files.pythonhosted.org/packages/37/36/7599ecabcf80ce4fef2e1ef3c5ac0d4696b61f03f724cc44022f4d226af9/zope_interface-8.5-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:03bbecc7982af713d7499d4084bc03916413d17ffd45f89009348cc0c1d9e376", size = 260711, upload-time = "2026-05-26T06:49:16.568Z" }, + { url = "https://files.pythonhosted.org/packages/03/3e/1774b0ee46ccbb5498ee3c33ece40315b6ef58bc71957be94bd345340bc1/zope_interface-8.5-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf917009a4a7457c7290225a019f4a0aa706d96accd2cfdba2418d3bc1fcde2f", size = 265277, upload-time = "2026-05-26T06:49:18.656Z" }, + { url = "https://files.pythonhosted.org/packages/b6/09/e533b2ffabaae4e5d5730d6768a591cf335defe8e37bec2ad905d09be656/zope_interface-8.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:31cff25b2aaedb5267e6e77b1e9be6b0ec4f622032de8a069202b8ffacda7dc2", size = 266369, upload-time = "2026-05-26T06:49:20.174Z" }, + { url = "https://files.pythonhosted.org/packages/49/4a/3ebe6a4c122b2d5340db45cbe7e490663d3228b172710ec71060cd5d541e/zope_interface-8.5-cp311-cp311-win_amd64.whl", hash = "sha256:17a3114bbdddb5e75e5784cdf318944636190cbbc72d357ef9fb1a8b0351f955", size = 215161, upload-time = "2026-05-26T06:49:21.799Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/056ad97af5b16db1975ee98ec7ab03d2ce3f3355efad904ced1dbce0e39f/zope_interface-8.5-cp311-cp311-win_arm64.whl", hash = "sha256:aab6bb5bee10f38ea688b95ba054396b67f613552d2c8378be7fcb2d2fba7646", size = 213481, upload-time = "2026-05-26T06:49:25.085Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/b84123a948f3162a34623e188922827cd845244fdd043ed20f8d02228caa/zope_interface-8.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:8e6ee90c2e6de7c37058d5fa41f123c8b13a312db8d1e0fb5840d7f4bcdff9c9", size = 212165, upload-time = "2026-05-26T06:49:26.566Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/cbceec44f1b27208a76c1a688c131302685852406a23df5aab68324109cc/zope_interface-8.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c1adc90d3576b3b4c4de4953e6002c37bef28b78d7fa54c1bbfd0c50f022fe7c", size = 212341, upload-time = "2026-05-26T06:49:28.182Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c3/005032195ff3b210c139b7c560ed5c534e844b0907d8e44d2b3d8919305e/zope_interface-8.5-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:e6347b8d8d12c5eca6502450a92be30079b7acfade2c4f693efa0deb8871b06e", size = 265296, upload-time = "2026-05-26T06:49:29.741Z" }, + { url = "https://files.pythonhosted.org/packages/c5/66/1036543d6a66bc04c19df3cf650f3ad938a002ab0a443c24e23e8de5e8b9/zope_interface-8.5-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5e970dabea777a24b0b0bbf9dae3ab75ce8b2d8e948edf4875627034b21f3560", size = 270689, upload-time = "2026-05-26T06:49:31.767Z" }, + { url = "https://files.pythonhosted.org/packages/30/4c/8b56259558cace4414e753ca6740396a1f59d4a95ddb55b4658600408670/zope_interface-8.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f0b48ccadaa9839e09ff81e969703cecb3f402c813bfe8b958652e699bea69f5", size = 270280, upload-time = "2026-05-26T06:49:33.489Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ea/649908c83aa8fdb7faf2ddca4d3cf6fb8f2157121267dc56e8f72681e26c/zope_interface-8.5-cp312-cp312-win_amd64.whl", hash = "sha256:e0e311f1277468c08fd59a2b41f71b43d25dff639789d364747acd1705c0df6e", size = 215019, upload-time = "2026-05-26T06:49:35.607Z" }, + { url = "https://files.pythonhosted.org/packages/9f/97/da13037b4c563e4df32eedbc819f8c00b754af494f68211e3dffd48d52da/zope_interface-8.5-cp312-cp312-win_arm64.whl", hash = "sha256:652b73107a04159ec6c020db6c1543d4f1e8f4d069bd2aac88a947820923517b", size = 213569, upload-time = "2026-05-26T06:49:37.317Z" }, + { url = "https://files.pythonhosted.org/packages/f4/8c/4c15755d701f2ec0e80d64a18e1ebaf5be2c584c0ec153fd516f5d13eada/zope_interface-8.5-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:28e80457c134d1fa57a7d758004dece348654e1b1467ac22dcdc20fc1d127c52", size = 212512, upload-time = "2026-05-26T06:49:38.996Z" }, + { url = "https://files.pythonhosted.org/packages/9a/2e/4360c54c465db042cc8fbeeec92abac28b4cedbf6ba63c1f092fd08a190f/zope_interface-8.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:09495ce9d559c06b70f2d4855b3e4f48a822a9ddc8be1d30c5b4e5be14ae1ace", size = 212541, upload-time = "2026-05-26T06:49:41.186Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a5/692a2b8d70f78e848793231d5fae5fecbf8d0cccd73430fdc34802a6d3c1/zope_interface-8.5-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:7849ad8fa90763cc1087f4dda78ca3a233e950b3e08fac7079297c9cafbbd7bb", size = 265191, upload-time = "2026-05-26T06:49:43.449Z" }, + { url = "https://files.pythonhosted.org/packages/70/8d/454a9cfc7a050c394ab4f11b3371f7897828b7415e096afff724637e65e0/zope_interface-8.5-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5578c9421ca409a1f39f153d6f7803e4cde01da592ec75a9ac5e1b777d18d33b", size = 270626, upload-time = "2026-05-26T06:49:45.425Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/db8409cfa3575b8e9b4800babd7d49f8228433cd1f0c56814bd0ada49c33/zope_interface-8.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e1bd7d96b4ca5fa311f54c9eac16dce4886b428c1531dbe06067763ccdf123b4", size = 270444, upload-time = "2026-05-26T06:49:47.025Z" }, + { url = "https://files.pythonhosted.org/packages/4a/df/a386940e41469ef615e100a216d8b386521e9e598817147f87932ca203c4/zope_interface-8.5-cp313-cp313-win_amd64.whl", hash = "sha256:0c8123d2a4dfde2a613c7cb772605477724782c20bc2e0ad1d9435376a6a44a3", size = 215021, upload-time = "2026-05-26T06:49:48.478Z" }, + { url = "https://files.pythonhosted.org/packages/89/75/477eb5669b6b2a7a843decd1a075e9b1971a8720017654143a7183abd3d9/zope_interface-8.5-cp313-cp313-win_arm64.whl", hash = "sha256:6d02be14f3173c6c7288bc2fdf530090c01c3cf8764ad46c68024686f364278e", size = 213610, upload-time = "2026-05-26T06:49:50.01Z" }, + { url = "https://files.pythonhosted.org/packages/d4/19/5032e954827fdf02db2d2f49737ac4378bb9cfc2cd95a8f2e2a5ae2ec01a/zope_interface-8.5-cp314-cp314-macosx_10_9_x86_64.whl", hash = "sha256:ffaecf013251a89d0de6feb49a46eba48ad8cbbf8a40aeb6045e459e7bec6784", size = 212597, upload-time = "2026-05-26T06:49:51.63Z" }, + { url = "https://files.pythonhosted.org/packages/f1/53/3ef644012cf8a6a234a2d6134aab5a5c65ac5467c86296865501d4fbc406/zope_interface-8.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:126fa9d1c52295ae076d4cf968634f0a1826afa408a20808b57ff72877b8f69f", size = 212626, upload-time = "2026-05-26T06:49:53.236Z" }, + { url = "https://files.pythonhosted.org/packages/32/67/bc8b4f465d388039255003e230c284a175cedf1203c692f23cb7bff64efe/zope_interface-8.5-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:3090e3a663d20194756a59a272e0c8508b889341e31d5894223331fe6b4f9b21", size = 266827, upload-time = "2026-05-26T06:49:54.873Z" }, + { url = "https://files.pythonhosted.org/packages/a7/eb/37d05b935ede53d79690fecc8d201440084418e590bcfc05f384451c7593/zope_interface-8.5-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9342fb74e2afefdb081bf1df727d209ea56995c6e13f5a0540e6d7aff4beafb8", size = 270139, upload-time = "2026-05-26T06:49:57.116Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/fd0c54579e2ce8dc6cf1a757903f3374bc6fbda929a46af9e0f53cb0e5f0/zope_interface-8.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6c54725d818f1b57a7efb8b16528326e1f3c257b602b32393fd255c45af8799d", size = 270338, upload-time = "2026-05-26T06:49:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1d/c420dcd777bb761067ea92879ac766694a5ca78608185f1aecea64cbfc11/zope_interface-8.5-cp314-cp314-win_amd64.whl", hash = "sha256:29d74febbae1afeb6834c4ccbf42e242a673c860060f09e53142825270456140", size = 215789, upload-time = "2026-05-26T06:50:00.405Z" }, + { url = "https://files.pythonhosted.org/packages/62/94/50b5eb8f94e527edceac14f9955e58917424ea79bb572ddc18548561cbc2/zope_interface-8.5-cp314-cp314-win_arm64.whl", hash = "sha256:633c8c49396f38df030340797c533e9fe460d1b5d1e42d88e55e938e525f548c", size = 213757, upload-time = "2026-05-26T06:50:01.973Z" }, + { url = "https://files.pythonhosted.org/packages/17/6f/5d5f32c4dfcdb16ce2ec5363da686840f13c13e1a1214cb70b49e1cd6d9f/zope_interface-8.5-cp314-cp314t-macosx_10_9_x86_64.whl", hash = "sha256:133999820fdbae513c36c03d6f29ef87317aaa3edef39112222b155083664714", size = 213591, upload-time = "2026-05-26T06:50:03.529Z" }, + { url = "https://files.pythonhosted.org/packages/f3/55/de0c3459ff717fce3342f9a29464c281fdeb0d36c3171ee88d119d5f0650/zope_interface-8.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8bd75c96966e573232f0599deaff717564828031c7f05563ccc1ac35c5ee0304", size = 213733, upload-time = "2026-05-26T06:50:05.101Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/d97430abd5ae9677e8b9295b58720c0064a5b557dbb6b8bf5928484cf0d8/zope_interface-8.5-cp314-cp314t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:14b0e9799351d4c34fe99afd67f0cdd76e55ba15c66a98699d5fc22ea8241e08", size = 294905, upload-time = "2026-05-26T06:50:07.384Z" }, + { url = "https://files.pythonhosted.org/packages/41/ec/a0f8f3dad6e74992f4654bdd94802be0929eabca7b871cac3b6fbb5e961b/zope_interface-8.5-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0cd6a732ac84b94eb1ef9222a117347a27efd294ee16810ffdf7ecd307677ed5", size = 300885, upload-time = "2026-05-26T06:50:08.997Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/6881b48803a0ee8d23eb5efa30fce3ed218a2bd9de5758ce489d224fee81/zope_interface-8.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:798b7c87d0e59a7d5d086d642208d0d8700ff0d55c4029134b3c479c3bfb110f", size = 304672, upload-time = "2026-05-26T06:50:10.563Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0e/b4c01320859ff1d585438bc231fd60bd258d096359bccf6654fecdf0cffb/zope_interface-8.5-cp314-cp314t-win_amd64.whl", hash = "sha256:0fc3a9d45f114d27eaa1e53beeb144533689edca8a9f66505b1e8e8b3f075e42", size = 217241, upload-time = "2026-05-26T06:50:12.171Z" }, +] From 38a1a8ba010f52b5a3ac1b0577bcad5305f76426 Mon Sep 17 00:00:00 2001 From: Roy Dahan Date: Wed, 19 Aug 2026 22:52:19 +0300 Subject: [PATCH 143/200] Address review feedback: rebase fallout, extension cleanup precision, uv.lock - Drop the gevent/eventlet reactor test invocations from coverage.sh: those reactors and their test files were removed from master (CASSPYTHON-13), so the paths no longer exist. - Fix the stale-extension cleanup to build exact module-name+suffix candidates instead of a generic endswith() scan: the old scan matched the bare .so/.pyd suffix on any file regardless of ABI tag, deleting foreign-ABI builds of real modules (e.g. cluster's) while paradoxically NOT excluding foreign-ABI cmurmur3/libevwrapper builds, since their computed "stem" retained the ABI tag and never equalled the excluded name. Verified against synthetic current-ABI/foreign-ABI/excluded-module files: only the intended current-ABI candidates get deleted now. - Regenerate uv.lock against the rebased pyproject.toml (uv lock --check now passes; removes the eventlet/gevent/futurist/twisted etc. entries that master's removal of those reactors already dropped upstream). --- scripts/coverage.sh | 49 ++- uv.lock | 730 +------------------------------------------- 2 files changed, 25 insertions(+), 754 deletions(-) diff --git a/scripts/coverage.sh b/scripts/coverage.sh index 767df74e92..f5acd44e79 100755 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -26,49 +26,44 @@ export CASS_DRIVER_NO_CYTHON=1 # CASS_DRIVER_NO_CYTHON=1 silently has no effect and coverage reports 0% for # every affected module (and, for the Cython-only modules with no .py # fallback like row_parser, HAVE_CYTHON would stay True off a stale .so, -# defeating CASS_DRIVER_NO_CYTHON entirely). Only extensions matching the -# *current* interpreter's own EXTENSION_SUFFIXES are removed -- the same -# mechanism tests/conftest.py already uses to detect staleness -- so this -# doesn't force a rebuild for other Python versions/venvs sharing this -# checkout. murmur3/libev are excluded by name since they're unaffected by -# CASS_DRIVER_NO_CYTHON. `--reinstall-package` then rebuilds from scratch, -# producing only the extensions CASS_DRIVER_NO_CYTHON=1 actually allows. If -# any of this setup fails, there's no point running any tests, so bail out +# defeating CASS_DRIVER_NO_CYTHON entirely). Candidates are built from the +# exact list of optionally-cythonized module names (setup.py's +# cython_candidates) plus the *current* interpreter's own EXTENSION_SUFFIXES, +# then only deleted if that exact file exists -- unlike a generic endswith() +# scan, this can't match a foreign-ABI build of the same module (e.g. +# cluster.cpython-311-...so when the current interpreter is 3.12) or a +# module never in the list (cmurmur3, libevwrapper are unaffected by +# CASS_DRIVER_NO_CYTHON and always left alone since they're simply not +# candidates). `--reinstall-package` then rebuilds from scratch, producing +# only the extensions CASS_DRIVER_NO_CYTHON=1 actually allows. If any of +# this setup fails, there's no point running any tests, so bail out # immediately -- failure tolerance below is scoped to test/report commands # only. uv run python -c " import importlib.machinery, pathlib -exclude = {'cmurmur3', 'libevwrapper'} -for path in pathlib.Path('cassandra').rglob('*'): +candidates = ['cluster', 'concurrent', 'connection', 'cqltypes', 'metadata', + 'pool', 'protocol', 'query', 'util', 'shard_info'] +for name in candidates: for suffix in importlib.machinery.EXTENSION_SUFFIXES: - if path.name.endswith(suffix): - if path.name[:-len(suffix)] not in exclude: - path.unlink() - break + path = pathlib.Path('cassandra') / (name + suffix) + if path.exists(): + path.unlink() " || exit 1 uv sync --reinstall-package scylla-driver || exit 1 status=0 -# Unlike the gevent/eventlet/asyncio reactor tests below, tests/unit/io/ -# test_asyncorereactor.py is deliberately NOT in the --ignore list: it needs -# no separate EVENT_LOOP_MANAGER run, since it self-skips via -# ASYNCCORE_AVAILABLE on Python 3.12+ (where the stdlib `asyncore` module was -# removed) and otherwise runs normally here, gaining coverage on 3.9-3.11. +# Unlike the asyncio reactor test below, tests/unit/io/test_asyncorereactor.py +# is deliberately NOT in the --ignore list: it needs no separate +# EVENT_LOOP_MANAGER run, since it self-skips via ASYNCCORE_AVAILABLE on +# Python 3.12+ (where the stdlib `asyncore` module was removed) and +# otherwise runs normally here, gaining coverage on 3.9-3.11. uv run coverage run -m pytest tests/unit -v \ --ignore=tests/unit/column_encryption \ - --ignore=tests/unit/io/test_geventreactor.py \ - --ignore=tests/unit/io/test_eventletreactor.py \ --ignore=tests/unit/io/test_asyncioreactor.py \ || status=1 -# gevent/eventlet monkey-patch threading/sockets, which can confuse -# coverage.py's default sys.settrace-based collector; --concurrency tells it -# about the greenlet scheduler explicitly. asyncio and the default (thread) -# runs need no such hint. -EVENT_LOOP_MANAGER=gevent uv run coverage run --concurrency=gevent,thread -m pytest tests/unit/io/test_geventreactor.py -v || status=1 EVENT_LOOP_MANAGER=asyncio uv run coverage run -m pytest tests/unit/io/test_asyncioreactor.py -v || status=1 -EVENT_LOOP_MANAGER=eventlet uv run coverage run --concurrency=eventlet,thread -m pytest tests/unit/io/test_eventletreactor.py -v || status=1 if [[ -n "${SCYLLA_VERSION:-}" || -n "${CASSANDRA_VERSION:-}" ]]; then uv run coverage run -m pytest tests/integration/standard tests/integration/cqlengine/ -v || status=1 diff --git a/uv.lock b/uv.lock index 26e86f3eaf..216e808caa 100644 --- a/uv.lock +++ b/uv.lock @@ -362,18 +362,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] -[[package]] -name = "automat" -version = "25.4.16" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e3/0f/d40bbe294bbf004d436a8bcbcfaadca8b5140d39ad0ad3d73d1a8ba15f14/automat-25.4.16.tar.gz", hash = "sha256:0017591a5477066e90d26b0e696ddc143baafd87b588cfac8100bc6be9634de0", size = 129977, upload-time = "2025-04-16T20:12:16.002Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/ff/1175b0b7371e46244032d43a56862d0af455823b5280a50c63d99cc50f18/automat-25.4.16-py3-none-any.whl", hash = "sha256:04e9bce696a8d5671ee698005af6e5a9fa15354140a87f4870744604dcdd3ba1", size = 42842, upload-time = "2025-04-16T20:12:14.447Z" }, -] - [[package]] name = "boto3" version = "1.42.97" @@ -833,15 +821,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] -[[package]] -name = "constantly" -version = "23.10.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4d/6f/cb2a94494ff74aa9528a36c5b1422756330a75a8367bf20bd63171fc324d/constantly-23.10.4.tar.gz", hash = "sha256:aa92b70a33e2ac0bb33cd745eb61776594dc48764b06c35e0efd050b7f1c7cbd", size = 13300, upload-time = "2023-10-28T23:18:24.316Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/40/c199d095151addf69efdb4b9ca3a4f20f70e20508d6222bffb9b76f58573/constantly-23.10.4-py3-none-any.whl", hash = "sha256:3fd9b4d1c3dc1ec9757f3c52aef7e53ad9323dbe39f51dfd4c43853b68dfa3f9", size = 13547, upload-time = "2023-10-28T23:18:23.038Z" }, -] - [[package]] name = "coverage" version = "7.10.7" @@ -1366,104 +1345,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/ec/e61deec9bcfbb0e1b36f8b5ba75cb44644419b4bfd0fdd666bffd21d9579/cython-3.2.9-py3-none-any.whl", hash = "sha256:a2b0e87f6b80790c929308ca0831d686f7a180feab684fe8cd4a4380bd96aaca", size = 1259272, upload-time = "2026-07-24T06:21:18.95Z" }, ] -[[package]] -name = "debtcollector" -version = "3.0.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.9.12' and python_full_version < '3.10'", - "python_full_version > '3.9' and python_full_version < '3.9.12'", - "python_full_version <= '3.9'", -] -dependencies = [ - { name = "wrapt", marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/31/e2/a45b5a620145937529c840df5e499c267997e85de40df27d54424a158d3c/debtcollector-3.0.0.tar.gz", hash = "sha256:2a8917d25b0e1f1d0d365d3c1c6ecfc7a522b1e9716e8a1a4a915126f7ccea6f", size = 31322, upload-time = "2024-02-22T15:39:20.674Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/ca/863ed8fa66d6f986de6ad7feccc5df96e37400845b1eeb29889a70feea99/debtcollector-3.0.0-py3-none-any.whl", hash = "sha256:46f9dacbe8ce49c47ebf2bf2ec878d50c9443dfae97cc7b8054be684e54c3e91", size = 23035, upload-time = "2024-02-22T15:39:18.99Z" }, -] - -[[package]] -name = "debtcollector" -version = "3.1.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] -dependencies = [ - { name = "wrapt", marker = "python_full_version >= '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ad/57/1bbe02be744995408d944cf46b8c818cf072873064b1cd3c79c11618b216/debtcollector-3.1.0.tar.gz", hash = "sha256:278a45608cf16e79c0ae10851d869185c6b78f86610df8f27a451a18c1fec732", size = 32951, upload-time = "2026-03-24T10:07:38.202Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/99/05/3f36aed56f0e1815fdc2ed4a9f2bd680a7bfe8819f21eacded2dc00fe283/debtcollector-3.1.0-py3-none-any.whl", hash = "sha256:c64e49a66c0b71289620fc2fdf89c03d740bddb20576ddd4f04ddc01da946668", size = 24408, upload-time = "2026-03-24T10:07:37.218Z" }, -] - -[[package]] -name = "dnspython" -version = "2.7.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.9.12' and python_full_version < '3.10'", - "python_full_version > '3.9' and python_full_version < '3.9.12'", - "python_full_version <= '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197, upload-time = "2024-10-05T20:14:59.362Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload-time = "2024-10-05T20:14:57.687Z" }, -] - -[[package]] -name = "dnspython" -version = "2.8.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] -sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, -] - -[[package]] -name = "eventlet" -version = "0.40.4" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.9.12' and python_full_version < '3.10'", - "python_full_version > '3.9' and python_full_version < '3.9.12'", - "python_full_version <= '3.9'", -] -dependencies = [ - { name = "dnspython", version = "2.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "greenlet", version = "3.2.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d1/d8/f72d8583db7c559445e0e9500a9b9787332370c16980802204a403634585/eventlet-0.40.4.tar.gz", hash = "sha256:69bef712b1be18b4930df6f0c495d2a882bf7b63aa111e7b6eeff461cfcaf26f", size = 565920, upload-time = "2025-11-26T13:57:31.126Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/22/6d/8e1fa901f6a8307f90e7bd932064e27a0062a4a7a16af38966a9c3293c52/eventlet-0.40.4-py3-none-any.whl", hash = "sha256:6326c6d0bf55810bece151f7a5750207c610f389ba110ffd1541ed6e5215485b", size = 364588, upload-time = "2025-11-26T13:57:29.09Z" }, -] - -[[package]] -name = "eventlet" -version = "0.41.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] -dependencies = [ - { name = "dnspython", version = "2.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "greenlet", version = "3.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8f/03/9562f7aa854e001e3a63034c0a97590a1546e4fe530abf511c2ce07b0cb1/eventlet-0.41.1.tar.gz", hash = "sha256:e91010caa1880bb511de6ce2ed2186ef3493e0762a4d3ee93e97a0fcccdaaa28", size = 566159, upload-time = "2026-07-15T08:15:01.353Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/55/92d859c16a37a9b70e2c682747750184b389a6b4d25321a4a1ec48d94b33/eventlet-0.41.1-py3-none-any.whl", hash = "sha256:6f7bb5c2309d1c4527bf15fc2a5da0b829e68e495430b890993b47dfea258ae5", size = 364580, upload-time = "2026-07-15T08:14:59.536Z" }, -] - [[package]] name = "exceptiongroup" version = "1.3.1" @@ -1613,54 +1494,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] -[[package]] -name = "futurist" -version = "3.2.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.9.12' and python_full_version < '3.10'", - "python_full_version > '3.9' and python_full_version < '3.9.12'", - "python_full_version <= '3.9'", -] -dependencies = [ - { name = "debtcollector", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/af/12/786f4aaf9d396d67b1b7b90f248ff994e916605d0751d08a0344a4a785a6/futurist-3.2.1.tar.gz", hash = "sha256:01dd4f30acdfbb2e2eb6091da565eded82d8cbaf6c48a36cc7f73c11cfa7fb3f", size = 49326, upload-time = "2025-08-29T15:06:57.733Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/5b/a4418215b594fa44dea7deae61fa406139e2e8acc6442d25f93d80c52c84/futurist-3.2.1-py3-none-any.whl", hash = "sha256:c76a1e7b2c6b264666740c3dffbdcf512bd9684b4b253a3068a0135b43729745", size = 40485, upload-time = "2025-08-29T15:06:56.476Z" }, -] - -[[package]] -name = "futurist" -version = "3.3.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.10.*'", -] -dependencies = [ - { name = "debtcollector", version = "3.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/35/45/b603e4f3f1c6bdec051ee533166c7f880c88bbf2c2ed0ac661861374302f/futurist-3.3.0.tar.gz", hash = "sha256:3b84fdce52eb5094b486d95b8b9b1117fdf040f364a96969fbc22df955f42558", size = 51902, upload-time = "2026-03-24T10:13:44.015Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/35/6b/29b561eef753e14c999931e2a5acf44a5623958d877d7265595b737dfc51/futurist-3.3.0-py3-none-any.whl", hash = "sha256:3ba50d57b6086e3ba3d8bf87402218ab9fc4e280592cf5a19a49c0b375c3a69d", size = 43106, upload-time = "2026-03-24T10:13:43.03Z" }, -] - -[[package]] -name = "futurist" -version = "3.4.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", -] -dependencies = [ - { name = "debtcollector", version = "3.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/c3/d6df974b3b488606a1064b02094e90ba8a35efa8072ab403cca2b9e3c51d/futurist-3.4.0.tar.gz", hash = "sha256:ed00c6f4c815cce9549157e8ec28624b2f5ec83f9577b0dbe54f7516137d43d3", size = 52282, upload-time = "2026-06-26T13:33:57.432Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/43/51/96f2ef5038dbd9db345b26b5850c1ad52131e42d16ec6ae6ed8eb2fcada4/futurist-3.4.0-py3-none-any.whl", hash = "sha256:a4c16e5522c0d9726e1f8ee2129b91b5593d054949ea3799f256241bd241bed5", size = 43051, upload-time = "2026-06-26T13:33:56.254Z" }, -] - [[package]] name = "geomet" version = "1.1.0" @@ -1674,193 +1507,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/90/3bc780df088d439714af8295196a4332a26559ae66fd99865e36f92efa9e/geomet-1.1.0-py3-none-any.whl", hash = "sha256:4372fe4e286a34acc6f2e9308284850bd8c4aa5bc12065e2abbd4995900db12f", size = 31522, upload-time = "2023-11-14T15:43:35.305Z" }, ] -[[package]] -name = "gevent" -version = "26.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, - { name = "cffi", version = "2.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, - { name = "greenlet", version = "3.2.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and platform_python_implementation == 'CPython'" }, - { name = "greenlet", version = "3.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and platform_python_implementation == 'CPython'" }, - { name = "zope-event", version = "6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "zope-event", version = "6.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "zope-interface", version = "8.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "zope-interface", version = "8.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9f/5c/92002455a57cb3634383e2b822e3bccf409f43cde34528e46428971475cf/gevent-26.7.0.tar.gz", hash = "sha256:5b333a556e38a302b1b8c80525bef16d437e16f1e7767947789406841856a102", size = 6729213, upload-time = "2026-07-22T20:16:04.713Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/60/878d0cdef05d952ac7f17ffe385143fb0f3720afce0f6ff5ddbf7aac0342/gevent-26.7.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:80e98fc808bd9cc5c911d78a443d214bf0c8f96c9fdd296893df7e40364d5f37", size = 2198781, upload-time = "2026-07-22T16:48:28.588Z" }, - { url = "https://files.pythonhosted.org/packages/69/79/6ce781b60049060e9d89b3d0fe60940353adeb39856aaad5ee925fd127e9/gevent-26.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bf4b946b47cc6fdbdf9221f891db9a44df92166435c027760ee7dbdfb4039adc", size = 2229318, upload-time = "2026-07-22T17:02:11.713Z" }, - { url = "https://files.pythonhosted.org/packages/6d/38/48898f35c2092d699b755b01918551358db72b554c63f553c8f027d2bf31/gevent-26.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:55ce0b7f87f9befcc788d77eb039b1de89a35f37afc31942e12c7ae090a563b8", size = 1700480, upload-time = "2026-07-22T16:26:16.794Z" }, - { url = "https://files.pythonhosted.org/packages/93/51/53370896942523c333699394ccad379d186648dbfb913f42ce094ddfb4b3/gevent-26.7.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:7f7143823ef99bc657534a2b6e8cbadedc910750cc0b4f4b4438a58d9fe43ab2", size = 1783048, upload-time = "2026-07-22T18:11:25.996Z" }, - { url = "https://files.pythonhosted.org/packages/ba/56/5a2cb36d75d3b626d6ffa116673b34442bf5afd6f7ab4b98e512c1b008d6/gevent-26.7.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:ca4019899830471910129968251c795c8aee59e225fd16326ae01c1f93f3cfa6", size = 1880257, upload-time = "2026-07-22T18:10:40.919Z" }, - { url = "https://files.pythonhosted.org/packages/e9/ed/ee7eb2f03a38a4f33b0f327bab16d3158b3a794588a84dba739cbf4a3e68/gevent-26.7.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:e4042da317a96d12110831cc404855f0c501a5a5aa476a7a18c3b480a5a59233", size = 1819378, upload-time = "2026-07-22T18:29:06.444Z" }, - { url = "https://files.pythonhosted.org/packages/5e/aa/e2a202c03cff4f49bba54cc321c3afc29eee9d6fc452f4aa94d2f00e7d3d/gevent-26.7.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:5c97ca98e1aae427a267eae0fbfe8d0884327e6b1cd51fc2ef6642b8b0b82701", size = 2136837, upload-time = "2026-07-22T16:48:29.939Z" }, - { url = "https://files.pythonhosted.org/packages/ac/64/4892fbca47aa4e06b86aef96d6146bd61175b23b7db431ec7937d73b2f72/gevent-26.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5d5d1864bc3db92d1f82d1790395eda99f98b47fd9f7ec02c4e182d7828a8251", size = 1794058, upload-time = "2026-07-22T18:07:10.644Z" }, - { url = "https://files.pythonhosted.org/packages/21/23/90bb7d0c6f59d2973bb8f4bd3164be00e466823dea01568e0cb36f2afb77/gevent-26.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:15fd2d88ed5370f8084079758758df91f26d2f68575e1ee76fce604ddba83e5e", size = 2159797, upload-time = "2026-07-22T17:02:13.229Z" }, - { url = "https://files.pythonhosted.org/packages/a3/67/4d1e315ee3052530fa8537e0cdf1f9c3a6606740f7372f420c7d12d5cf82/gevent-26.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:514bda3fff741d7e5ab108ee1d31550a7f4b2fd3dc6e3b6f38dfb8685efdafaa", size = 1682414, upload-time = "2026-07-22T16:26:22.18Z" }, - { url = "https://files.pythonhosted.org/packages/02/b1/d1b1de89677ee39e641ad8501ed72c2b99f1ddba1c14c462675f40b37a66/gevent-26.7.0-cp311-cp311-win_arm64.whl", hash = "sha256:0f26f9a8c32ac0a73f6084c59b63deeacb350e7f1fee5301d95c5e0683a390d4", size = 1562794, upload-time = "2026-07-22T16:27:53.205Z" }, - { url = "https://files.pythonhosted.org/packages/2b/66/104590ad3a9e671b3ef77ad19c1cc50e7f1c8c220b27ddfaf34e5f88bd9c/gevent-26.7.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:92f256285fb43a57f152bd2e51a59cde1cd0b20869ae1e6da583b6beab88ed8a", size = 2953977, upload-time = "2026-07-22T16:23:41.654Z" }, - { url = "https://files.pythonhosted.org/packages/64/07/350d87161378633714184828bdc57c66f9b525eca5249ad1294dc7f8cf58/gevent-26.7.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:0e4fea187c5df7168b9538b4f543fcb0fcbaeb93be3d6cd499c324652c740704", size = 1800960, upload-time = "2026-07-22T18:11:27.242Z" }, - { url = "https://files.pythonhosted.org/packages/c4/6c/ddfe298c2ecb1cfc72a03dd69ba751759f7e7835d3135f171b284eb09ed1/gevent-26.7.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:ce732fe08d0ea65de07eff6e46bade8ac6a6fdb65cc748c713f3d31ae122529e", size = 1900387, upload-time = "2026-07-22T18:10:42.973Z" }, - { url = "https://files.pythonhosted.org/packages/f9/89/2647bbbf1da35a1c271a54452e76065308cbaf684ee32a79f989ca4265f6/gevent-26.7.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:c25b3522072137aecf3389031039230190038f888e257f490b3897d0e0620f74", size = 1848046, upload-time = "2026-07-22T18:29:08.074Z" }, - { url = "https://files.pythonhosted.org/packages/31/52/4f9b4c536b5a0424e328d5a5466640d185020f1f0b08b2de0ca939cc0a0b/gevent-26.7.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:eaaa75c9014df3f8c310c64f53f1152af8c6be32e82734396bed91e1d0e6f35c", size = 2132200, upload-time = "2026-07-22T16:48:31.203Z" }, - { url = "https://files.pythonhosted.org/packages/42/88/1daffa63b257c381df68018e4d3da72b429955cf95a171a46d3220422c8d/gevent-26.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f1956032a9926ac9b4152b2a50bc5a2cc020722ec16928ccaf32e227ee0aae47", size = 1814237, upload-time = "2026-07-22T18:07:12.438Z" }, - { url = "https://files.pythonhosted.org/packages/73/4c/b996cdddca78eac435195cd97dff590c65a9e0052640bcb6f8b6f1e30b1d/gevent-26.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d989a1ad6cc54f5c69bb7304360f98b4fda80da2b773f1047db9fba61ae7379a", size = 2157938, upload-time = "2026-07-22T17:02:14.887Z" }, - { url = "https://files.pythonhosted.org/packages/08/fd/44419d7559a95e238ee45d29df30bad78a91d343cb27b13a6af552b907bb/gevent-26.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:e0c9ce2d80fc0f8894d748a1045ff26ad188e294bad656b29839271800827c85", size = 1685319, upload-time = "2026-07-22T16:26:13.954Z" }, - { url = "https://files.pythonhosted.org/packages/6f/7a/7df1762ccd9a40ce1ca626f50cb7a75758f2c930aabcc73c91cf00cb3448/gevent-26.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:959effe0c56cdee0bf761e5c4e78ab62880be147a2f2aa31112ca2f7e5754e53", size = 1558945, upload-time = "2026-07-22T16:26:56.737Z" }, - { url = "https://files.pythonhosted.org/packages/75/63/0fcfbe3f5696e56424f331ec41e0e447cea79c384848d6019e3f7f340f4b/gevent-26.7.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b1b89eb5566f75aa8b2bbdb0308e1ac8d9113ca7cff85b45366aea9faad639a1", size = 2976844, upload-time = "2026-07-22T16:24:39.275Z" }, - { url = "https://files.pythonhosted.org/packages/3c/6c/ea2d0afbe760c18df5bd1631dbe5a73d840d9b141cb71e6810157c2ae28a/gevent-26.7.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:449857ce058183442e2d71d83ff0c587a3ddff631e93c6d19a6dffb4814eccad", size = 1802332, upload-time = "2026-07-22T18:11:29.155Z" }, - { url = "https://files.pythonhosted.org/packages/59/90/36f2258f1bfe8601224f6159066103b832c31e1451ce8dc2cd2408b6ecf4/gevent-26.7.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:8260a3f38b05fcf3c283417b18617562dbec74f5784f748e4ba3866789d7f3a4", size = 1901253, upload-time = "2026-07-22T18:10:44.402Z" }, - { url = "https://files.pythonhosted.org/packages/dd/38/86dd67e5c2dfab016a9c935b4338d6cf8f9bfa72dc5a0f3fb879d993127a/gevent-26.7.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:30894398d06747b433c8923a6a77ede61259ce6822a99f6c6e7fa0216ccb73c3", size = 1850489, upload-time = "2026-07-22T18:29:09.694Z" }, - { url = "https://files.pythonhosted.org/packages/f1/33/f5651942a5967483298b6ce6f45572d33120dd0fd01c8991d3fca5b1e8ee/gevent-26.7.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:0b753522498118c9489753de7c612d4baed0edf384d9df2bf9492233ba1c20ff", size = 2129813, upload-time = "2026-07-22T16:48:32.551Z" }, - { url = "https://files.pythonhosted.org/packages/c0/09/abe8217a8fcd3f0e94c9eec024a5499c0f267cb269ccea6c0e2e812319b9/gevent-26.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:055a643026dc28daff2be228555a2097937448cc9b58307edebcf81b9d78ff4b", size = 1815121, upload-time = "2026-07-22T18:07:13.988Z" }, - { url = "https://files.pythonhosted.org/packages/40/d6/dbae1cd2d27b62664cefa086035530eb21203d45b12f466272441c048c9c/gevent-26.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4e1dc6a2712de67fd210e1f1a408601f6908b042f6420e188106f2f37f94ec71", size = 2155913, upload-time = "2026-07-22T17:02:16.35Z" }, - { url = "https://files.pythonhosted.org/packages/fc/42/90b662f4eb27d7727d4619d5c6be872117f1a9f187b243ec7f6fef988ce7/gevent-26.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:44e5280296129c0915addaefdb37d6e9bc124a77a433b1b1c8ddf1853c53f4e7", size = 1682483, upload-time = "2026-07-22T16:26:30.492Z" }, - { url = "https://files.pythonhosted.org/packages/e1/84/7297c56b9fff463c4ba2f685dbb913a855df903046dc68d14e8655a29ffe/gevent-26.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f08b1aa6729f794409ca137e25f671e0d9bbda4451200c5e28a769375365388", size = 1556053, upload-time = "2026-07-22T16:26:14.365Z" }, - { url = "https://files.pythonhosted.org/packages/c1/bb/ab60d496cbdc0293ebbd6c2070b34da0632bd7a2ca20163c17e18d2d2dc9/gevent-26.7.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:0e0e3bf7ae0f82dbc5c6be26b4781e86c97f1e28d516b7a9746ac8b04bcc6948", size = 2992503, upload-time = "2026-07-22T16:24:36.503Z" }, - { url = "https://files.pythonhosted.org/packages/5c/35/75f27c06a82a5b22600aaccbd9567d89bb4091be43e96c02981f10aff23d/gevent-26.7.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:740050b53048207b080a1e183a377c47809ad0b7b7b0cd7eab0dea1045f7e480", size = 1809173, upload-time = "2026-07-22T18:11:30.724Z" }, - { url = "https://files.pythonhosted.org/packages/5b/5f/a6b32b4db3fa76bd8a070f0f46f5306123bf6336e7a0ca0cd2f9b99473df/gevent-26.7.0-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:67983607eb6c7bafa362c5c43b69a27145b936c34a3d6441ed42413d62fae0a6", size = 1906630, upload-time = "2026-07-22T18:10:45.836Z" }, - { url = "https://files.pythonhosted.org/packages/e1/87/832495d8fcc05ff7432f038b7c4decbd5632425a2cd5da2ce73cb2d800c4/gevent-26.7.0-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:475848518d708e07d1987c3d94cb8ff53e2b3a69df32e39feda2779cafe400b0", size = 1855278, upload-time = "2026-07-22T18:29:11.517Z" }, - { url = "https://files.pythonhosted.org/packages/72/8b/2f36c0fa389fa2b7ceb5a8972b0e7da7bc770f9135315cf4246c607ca5fc/gevent-26.7.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:0f8ed457dd616bfe6682569f92730f9ab45aafb1aeca5e80eb2f6b9a2ce26d11", size = 2136155, upload-time = "2026-07-22T16:48:33.865Z" }, - { url = "https://files.pythonhosted.org/packages/b5/98/09f2cfaa23dbce48e3271e95b0d003f93acece6b5cfd40f4cebe3850d79b/gevent-26.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15373c68cf1fa14114bec2f09b16e2c65374bd5309e897e0a28740b09ce329e0", size = 1822108, upload-time = "2026-07-22T18:07:15.397Z" }, - { url = "https://files.pythonhosted.org/packages/b0/d8/05a294165c17569f04284ad3c889684c8780544885b4cdf77b1432947d0c/gevent-26.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:73f3d53f2f390369e290c933b75bd87f1f2261f2f2f2175aa667c43ee3049bad", size = 2162814, upload-time = "2026-07-22T17:02:18.066Z" }, - { url = "https://files.pythonhosted.org/packages/59/89/58a545c4eda33e106d6887a0387adc2249abc14c779e3eb88bbfdf3768d6/gevent-26.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:f11b558d544ad2249029ba023cd6519ec3a0eee54a3d027e6515c1eaa322422a", size = 1706971, upload-time = "2026-07-22T16:27:02.263Z" }, - { url = "https://files.pythonhosted.org/packages/bb/cd/413f293e54961e5c89c54235370e3603ec0f561e7ace8357980410efbf78/gevent-26.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:3871f4ca59ec2328c3ef638a0fe01a28a825443a133368dc78eb5ceadcad7609", size = 1585078, upload-time = "2026-07-22T16:30:48.145Z" }, - { url = "https://files.pythonhosted.org/packages/21/3a/47f29f632aaa38aa12410f57f1732fc50bfd4d4006d2e7e022ce731cabc9/gevent-26.7.0-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:3e3d6e20a94239ad353b776e72b8ce18c35dbe4e98c279aef3932651553d8404", size = 2996208, upload-time = "2026-07-22T16:23:23.859Z" }, - { url = "https://files.pythonhosted.org/packages/b8/b3/4620f1ce81ecec9890229806c73f07dd022e40f76a552bd430391e7316c4/gevent-26.7.0-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:ddbd3cc76b9bc69df651a216c2a62fc6415ad463b3ac9c6cbbbb8b7b8224af17", size = 1811545, upload-time = "2026-07-22T18:11:32.224Z" }, - { url = "https://files.pythonhosted.org/packages/97/fd/d285212ffd5585d511299e13e61d76262def8e826e9f20c92cb85df406f2/gevent-26.7.0-cp315-cp315-manylinux_2_28_ppc64le.whl", hash = "sha256:01ceab7e608dc1b9859d9511a0a29d7ce2e7d909ab19fddc860e70a2ed5b10ce", size = 1910418, upload-time = "2026-07-22T18:10:47.709Z" }, - { url = "https://files.pythonhosted.org/packages/2d/e0/c5d666e6065652918cfb6e6a3cf8f721d0e152c57cec217ad81792a1323b/gevent-26.7.0-cp315-cp315-manylinux_2_28_s390x.whl", hash = "sha256:2e6c917b2b8baeb6080797a6b25e35e1fd784319a05bb92b87c53546e5578eb2", size = 1857891, upload-time = "2026-07-22T18:29:13.13Z" }, - { url = "https://files.pythonhosted.org/packages/a1/67/e945ed458fa98b34572876bfd0d35fe4fa3f1159f43660b71d982b7cb63e/gevent-26.7.0-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:df75a1748b26030f2f7f10042cc45640b22954d9d0dc6b4b6f0dbe0b6751a2d4", size = 2138121, upload-time = "2026-07-22T16:48:35.358Z" }, - { url = "https://files.pythonhosted.org/packages/99/55/622468fa1a3c4cf51f20e14813f5cc1592fe6e44a42ccca9195a0b18c769/gevent-26.7.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:ee1b389587e5d5c1eb19d0455b5b4d7a0fb5c5287af4e226ec66d9dfd2548107", size = 1825114, upload-time = "2026-07-22T18:07:16.667Z" }, - { url = "https://files.pythonhosted.org/packages/0f/7a/151a2afcacf487ca25faf8b1bdd6c5b4ace2f7c1e6b4eaffe0a5e6e1df61/gevent-26.7.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c2918641ba756f46aa01ab9dd82d6dfceec403c77c2787298746b411dcf0288e", size = 2165990, upload-time = "2026-07-22T17:02:19.817Z" }, -] - -[[package]] -name = "greenlet" -version = "3.2.5" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.9.12' and python_full_version < '3.10'", - "python_full_version > '3.9' and python_full_version < '3.9.12'", - "python_full_version <= '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/b0/f5/3e9eafb4030588337b2a2ae4df46212956854e9069c07b53aa3caabafd47/greenlet-3.2.5.tar.gz", hash = "sha256:c816554eb33e7ecf9ba4defcb1fd8c994e59be6b4110da15480b3e7447ea4286", size = 191501, upload-time = "2026-02-20T20:08:51.539Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/d6/b3db928fc329b1b19ba32ffe143d2305f3aaafc583f5e1074c74ec445189/greenlet-3.2.5-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:34cc7cf8ab6f4b85298b01e13e881265ee7b3c1daf6bc10a2944abc15d4f87c3", size = 275803, upload-time = "2026-02-20T20:06:42.541Z" }, - { url = "https://files.pythonhosted.org/packages/b3/ff/ab0ad4ff3d9e1faa266de4f6c79763b33fccd9265995f2940192494cc0ec/greenlet-3.2.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c11fe0cfb0ce33132f0b5d27eeadd1954976a82e5e9b60909ec2c4b884a55382", size = 633556, upload-time = "2026-02-20T20:30:41.594Z" }, - { url = "https://files.pythonhosted.org/packages/da/dd/7b3ac77099a1671af8077ecedb12c9a1be1310e4c35bb69fd34c18ab6093/greenlet-3.2.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:a145f4b1c4ed7a2c94561b7f18b4beec3d3fb6f0580db22f7ed1d544e0620b34", size = 644943, upload-time = "2026-02-20T20:37:23.084Z" }, - { url = "https://files.pythonhosted.org/packages/56/f0/bea7e7909ea9045b0c5055dad1ec9b81c82b761b4567e625f4f8349acfa1/greenlet-3.2.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:edbf4ab9a7057ee430a678fe2ef37ea5d69125d6bdc7feb42ed8d871c737e63b", size = 640849, upload-time = "2026-02-20T20:43:57.305Z" }, - { url = "https://files.pythonhosted.org/packages/0f/36/84630e9ff1dfc8b7690957c0f77834a84eabdbd9c4977c3a2d0cbd5325c2/greenlet-3.2.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc1d01bdd67db3e5711e6246e451d7a0f75fae7bbf40adde129296a7f9aa7cc9", size = 639841, upload-time = "2026-02-20T20:07:17.473Z" }, - { url = "https://files.pythonhosted.org/packages/12/c4/6a2ee6c676dea7a05a3c3c1291fbc8ea44f26456b0accc891471293825af/greenlet-3.2.5-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd593db7ee1fa8a513a48a404f8cc4126998a48025e3f5cbbc68d51be0a6bf66", size = 588813, upload-time = "2026-02-20T20:07:56.171Z" }, - { url = "https://files.pythonhosted.org/packages/01/c0/75e75c2c993aa850292561ec80f5c263e3924e5843aa95a38716df69304c/greenlet-3.2.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ac8db07bced2c39b987bba13a3195f8157b0cfbce54488f86919321444a1cc3c", size = 1117377, upload-time = "2026-02-20T20:32:48.452Z" }, - { url = "https://files.pythonhosted.org/packages/ee/03/e38ebf9024a0873fe8f60f5b7bc36bfb3be5e13efe4d798240f2d1f0fb73/greenlet-3.2.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4544ab2cfd5912e42458b13516429e029f87d8bbcdc8d5506db772941ae12493", size = 1141246, upload-time = "2026-02-20T20:06:23.576Z" }, - { url = "https://files.pythonhosted.org/packages/d8/7b/c6e1192c795c0c12871e199237909a6bd35757d92c8472c7c019959b8637/greenlet-3.2.5-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:acabf468466d18017e2ae5fbf1a5a88b86b48983e550e1ae1437b69a83d9f4ac", size = 276916, upload-time = "2026-02-20T20:06:18.166Z" }, - { url = "https://files.pythonhosted.org/packages/3e/b6/9887b559f3e1952d23052ec352e9977e808a2246c7cb8282a38337221e88/greenlet-3.2.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:472841de62d60f2cafd60edd4fd4dd7253eb70e6eaf14b8990dcaf177f4af957", size = 636107, upload-time = "2026-02-20T20:30:43.362Z" }, - { url = "https://files.pythonhosted.org/packages/8a/be/e3e48b63bbc27d660fa1d98aecb64906b90a12e686a436169c1330ef34b2/greenlet-3.2.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d951e7d628a6e8b68af469f0fe4f100ef64c4054abeb9cdafbfaa30a920c950", size = 648240, upload-time = "2026-02-20T20:37:24.608Z" }, - { url = "https://files.pythonhosted.org/packages/17/f6/2cbe999683f759f14f598234f04ae8ba6f22953a624b3a7a630003e6bfff/greenlet-3.2.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:87b791dd0e031a574249af717ac36f7031b18c35329561c1e0368201c18caf1f", size = 644170, upload-time = "2026-02-20T20:43:59.002Z" }, - { url = "https://files.pythonhosted.org/packages/4c/ac/e731ed62576e91e533b36d0d97325adc2786674ab9e48ed8a6a24f4ef4e9/greenlet-3.2.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8317d732e2ae0935d9ed2af2ea876fa714cf6f3b887a31ca150b54329b0a6e9", size = 643313, upload-time = "2026-02-20T20:07:19.012Z" }, - { url = "https://files.pythonhosted.org/packages/70/64/99e5cdceb494bd4c1341c45b93f322601d2c8a5e1e4d1c7a2d24c5ed0570/greenlet-3.2.5-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce8aed6fdd5e07d3cbb988cbdc188266a4eb9e1a52db9ef5c6526e59962d3933", size = 591295, upload-time = "2026-02-20T20:07:57.286Z" }, - { url = "https://files.pythonhosted.org/packages/ee/e9/968e11f388c2b8792d3b8b40a57984c894a3b4745dae3662dce722653bc5/greenlet-3.2.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:60c06b502d56d5451f60ca665691da29f79ed95e247bcf8ce5024d7bbe64acb9", size = 1120277, upload-time = "2026-02-20T20:32:50.103Z" }, - { url = "https://files.pythonhosted.org/packages/cb/2c/b5f2c4c68d753dce08218dc5a6b21d82238fdfdc44309032f6fe24d285e6/greenlet-3.2.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d2a78e6f1bf3f1672df91e212a2f8314e1e7c922f065d14cbad4bc815059467", size = 1145746, upload-time = "2026-02-20T20:06:26.296Z" }, - { url = "https://files.pythonhosted.org/packages/ad/32/022b21523eee713e7550162d5ca6aed23f913cc2c6232b154b9fd9badc07/greenlet-3.2.5-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:2acb30e77042f747ca81f0a10cc153296567e92e666c5e1b117f4595afd43352", size = 278412, upload-time = "2026-02-20T20:03:15.02Z" }, - { url = "https://files.pythonhosted.org/packages/90/c5/8a3b0ed3cc34d8b988a44349437dfa0941f9c23ac108175f7b4ccea97111/greenlet-3.2.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:393c03c26c865f17f31d8db2f09603fadbe0581ad85a5d5908b131549fc38217", size = 644616, upload-time = "2026-02-20T20:30:44.823Z" }, - { url = "https://files.pythonhosted.org/packages/b1/2c/2627bea183554695016af6cae93d7474fa90f61e5a6601a84ae7841cb720/greenlet-3.2.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:04e6a202cde56043fd355fefd1552c4caa5c087528121871d950eb4f1b51fa99", size = 658813, upload-time = "2026-02-20T20:37:26.255Z" }, - { url = "https://files.pythonhosted.org/packages/44/c6/a80fc96f7cca7962dd972875d12c52dfabc94cb02bfeb19f3e7e169fca44/greenlet-3.2.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d5583b2ffa677578a384337ee13125bdf9a427485d689014b39d638a4f3d8dbe", size = 653512, upload-time = "2026-02-20T20:44:00.343Z" }, - { url = "https://files.pythonhosted.org/packages/2f/1b/75a5aeff487a26ba427a3837da6372f1fe6f2a9c6b2898e28ac99d491c11/greenlet-3.2.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:45fcea7b697b91290b36eafc12fff479aca6ba6500d98ef6f34d5634c7119cbe", size = 655426, upload-time = "2026-02-20T20:07:20.124Z" }, - { url = "https://files.pythonhosted.org/packages/53/91/9b5dfb4f3c88f8247c7a8f4c3759f0740bfa6bb0c59a9f6bf938e913df56/greenlet-3.2.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f96e2bb8a56b7e1aed1dbfbbe0050cb2ecca99c7c91892fd1771e3afab63b3e3", size = 611138, upload-time = "2026-02-20T20:07:58.966Z" }, - { url = "https://files.pythonhosted.org/packages/b4/8d/d0b086410512d9859c84e9242a9b341de9f5566011ddf3a3f6886b842b61/greenlet-3.2.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d7456e67b0be653dfe643bb37d9566cd30939c80f858e2ce6d2d54951f75b14a", size = 1126896, upload-time = "2026-02-20T20:32:52.198Z" }, - { url = "https://files.pythonhosted.org/packages/ef/37/59fe12fe456e84ced6ba71781e28cde52a3124d1dd2077bc1727021f49fd/greenlet-3.2.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5ceb29d1f74c7280befbbfa27b9bf91ba4a07a1a00b2179a5d953fc219b16c42", size = 1154779, upload-time = "2026-02-20T20:06:27.583Z" }, - { url = "https://files.pythonhosted.org/packages/dd/95/d5d332fb73affaf7a1fbe80e49c2c7eae4f17c645af24a3b3fa25736d6f0/greenlet-3.2.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:f2cc88b50b9006b324c1b9f5f3552f9d4564c78af57cdfb4c7baf4f0aa089146", size = 277166, upload-time = "2026-02-20T20:03:57.077Z" }, - { url = "https://files.pythonhosted.org/packages/6c/77/89458e20db5a4f1c64f9a0191561227e76d809941ca2d7529006d17d3450/greenlet-3.2.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e66872daffa360b2537170b73ad530f14fa31785b1bc78080125d92edf0a6def", size = 644674, upload-time = "2026-02-20T20:30:46.118Z" }, - { url = "https://files.pythonhosted.org/packages/90/f8/9962175d2f2eaa629a7fd7545abacc8c4deda3baa4e52c1526d2eb5f5546/greenlet-3.2.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c5445ddb7b586d870dad32ca9fc47c287d6022a528d194efdb8912093c5303ad", size = 658834, upload-time = "2026-02-20T20:37:27.466Z" }, - { url = "https://files.pythonhosted.org/packages/81/71/52c21a7106ce5218aa6fa59ec32825b2655f875a09b69f68bd3e5d01feb3/greenlet-3.2.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd904626b8779810062cb455514594776e3cba3b8c0ba4939894df9f7b384971", size = 653091, upload-time = "2026-02-20T20:44:01.927Z" }, - { url = "https://files.pythonhosted.org/packages/f5/d7/826d0e080f0a7ad5ec47c8d143bbd3ca0887657bb806595fe2434d12938a/greenlet-3.2.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:752c896a8c976548faafe8a306d446c6a4c68d4fd24699b84d4393bd9ac69a8e", size = 655760, upload-time = "2026-02-20T20:07:21.551Z" }, - { url = "https://files.pythonhosted.org/packages/41/cc/33bd4c2f816be8c8e16f71740c4130adf3a66a3dd2ba29de72b9d8dd1096/greenlet-3.2.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:499b809e7738c8af0ff9ac9d5dd821cb93f4293065a9237543217f0b252f950a", size = 614132, upload-time = "2026-02-20T20:08:00.351Z" }, - { url = "https://files.pythonhosted.org/packages/48/79/f3891dcfc59097474a53cc3c624f2f2465e431ab493bda043b8c873fb20a/greenlet-3.2.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2c7429f6e9cea7cbf2637d86d3db12806ba970f7f972fcab39d6b54b4457cbaf", size = 1125286, upload-time = "2026-02-20T20:32:54.032Z" }, - { url = "https://files.pythonhosted.org/packages/ca/47/212b47e6d2d7a04c4083db1af2fdd291bc8fe99b7e3571bfa560b65fc361/greenlet-3.2.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a5e4b25e855800fba17713020c5c33e0a4b7a1829027719344f0c7c8870092a2", size = 1152825, upload-time = "2026-02-20T20:06:29Z" }, - { url = "https://files.pythonhosted.org/packages/f6/9d/4e9b941be05f8da7ba804c6413761d2c11cca05994cbf0a015bd729419f0/greenlet-3.2.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7123b29e6bad2f3f89681be4ef316480fca798ebe8d22fbaced9cc3775007a4f", size = 277627, upload-time = "2026-02-20T20:06:04.798Z" }, - { url = "https://files.pythonhosted.org/packages/23/cb/a73625c9a35138330014ecf3740c0d62e0c2b5e7279bb7f2586b1b199fac/greenlet-3.2.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6e8fe0c72603201a86b2e038daf9b6c8570715f8779566419cff543b6ace88de", size = 690001, upload-time = "2026-02-20T20:30:47.754Z" }, - { url = "https://files.pythonhosted.org/packages/83/49/6d1531109507bce7dfb23acf57a87013627ed3ac058851176e443a6a9134/greenlet-3.2.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:050703a60603db0e817364d69e048c70af299040c13a7e67792b9e62d4571196", size = 702953, upload-time = "2026-02-20T20:37:29.125Z" }, - { url = "https://files.pythonhosted.org/packages/90/ac/6d8fff3b273fc60ad4b46f8411fe91c1e4cca064dfff68d096bc982fa6d0/greenlet-3.2.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:04633da773ae432649a3f092a8e4add390732cc9e1ab52c8ff2c91b8dc86f202", size = 698353, upload-time = "2026-02-20T20:44:03.547Z" }, - { url = "https://files.pythonhosted.org/packages/f7/38/f958ee90fab93529b30cc1e4a59b27c1112b640570043a84af84da3b3b98/greenlet-3.2.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6712bfd520530eb67331813f7112d3ee18e206f48b3d026d8a96cd2d2ad20251", size = 698995, upload-time = "2026-02-20T20:07:22.663Z" }, - { url = "https://files.pythonhosted.org/packages/51/c1/a603906e79716d61f08afedaf8aed62017661457aef233d62d6e57ecd511/greenlet-3.2.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bc06a78fa3ffbe2a75f1ebc7e040eacf6fa1050a9432953ab111fbbbf0d03c1", size = 661175, upload-time = "2026-02-20T20:08:01.477Z" }, - { url = "https://files.pythonhosted.org/packages/3f/8f/f880ff4587d236b4d06893fb34da6b299aa0d00f6c8259673f80e1b6d63c/greenlet-3.2.5-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:dbe0e81e24982bb45907ca20152b31c2e3300ca352fdc4acbd4956e4a2cbc195", size = 274946, upload-time = "2026-02-20T20:05:21.979Z" }, - { url = "https://files.pythonhosted.org/packages/3c/50/f6c78b8420187fdfe97fcf2e6d1dd243a7742d272c32fd4d4b1095474b37/greenlet-3.2.5-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:15871afc0d78ec87d15d8412b337f287fc69f8f669346e391585824970931c48", size = 631781, upload-time = "2026-02-20T20:30:48.845Z" }, - { url = "https://files.pythonhosted.org/packages/26/d6/3277f92e1961e6e9f41d9f173ea74b5c1f7065072637669f761626f26cc0/greenlet-3.2.5-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5bf0d7d62e356ef2e87e55e46a4e930ac165f9372760fb983b5631bb479e9d3a", size = 643740, upload-time = "2026-02-20T20:37:30.639Z" }, - { url = "https://files.pythonhosted.org/packages/f8/8a/c37b87659378759f158dbe03eaeb7ed002a8968f1c649b2972f5323f99b2/greenlet-3.2.5-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:e3f03ddd7142c758ab41c18089a1407b9959bd276b4e6dfbd8fd06403832c87a", size = 639098, upload-time = "2026-02-20T20:44:07.287Z" }, - { url = "https://files.pythonhosted.org/packages/2a/6a/4f79d2e7b5ef3723fc5ffea0d6cb22627e5f95e0f19c973fa12bf1cf7891/greenlet-3.2.5-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6dff6433742073e5b6ad40953a78a0e8cddcb3f6869e5ea635d29a810ca5e7d0", size = 638382, upload-time = "2026-02-20T20:07:23.883Z" }, - { url = "https://files.pythonhosted.org/packages/4d/59/7aadf33f23c65dbf4db27e7f5b60c414797a61e954352ae4a86c5c8b0553/greenlet-3.2.5-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdd67619cefe1cc9fcab57c8853d2bb36eca9f166c0058cc0d428d471f7c785c", size = 587516, upload-time = "2026-02-20T20:08:02.841Z" }, - { url = "https://files.pythonhosted.org/packages/1d/46/b3422959f830de28a4eea447414e6bd7b980d755892f66ab52ad805da1c4/greenlet-3.2.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:3828b309dfb1f117fe54867512a8265d8d4f00f8de6908eef9b885f4d8789062", size = 1115818, upload-time = "2026-02-20T20:32:55.786Z" }, - { url = "https://files.pythonhosted.org/packages/54/4a/3d1c9728f093415637cf3696909fa10852632e33e68238fb8ca60eb90de1/greenlet-3.2.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:67725ae9fea62c95cf1aa230f1b8d4dc38f7cd14f6103d1df8a5a95657eb8e54", size = 1140219, upload-time = "2026-02-20T20:06:30.334Z" }, -] - -[[package]] -name = "greenlet" -version = "3.3.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] -sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/3f/9859f655d11901e7b2996c6e3d33e0caa9a1d4572c3bc61ed0faa64b2f4c/greenlet-3.3.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9bc885b89709d901859cf95179ec9f6bb67a3d2bb1f0e88456461bd4b7f8fd0d", size = 277747, upload-time = "2026-02-20T20:16:21.325Z" }, - { url = "https://files.pythonhosted.org/packages/fb/07/cb284a8b5c6498dbd7cba35d31380bb123d7dceaa7907f606c8ff5993cbf/greenlet-3.3.2-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b568183cf65b94919be4438dc28416b234b678c608cafac8874dfeeb2a9bbe13", size = 579202, upload-time = "2026-02-20T20:47:28.955Z" }, - { url = "https://files.pythonhosted.org/packages/ed/45/67922992b3a152f726163b19f890a85129a992f39607a2a53155de3448b8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:527fec58dc9f90efd594b9b700662ed3fb2493c2122067ac9c740d98080a620e", size = 590620, upload-time = "2026-02-20T20:55:55.581Z" }, - { url = "https://files.pythonhosted.org/packages/03/5f/6e2a7d80c353587751ef3d44bb947f0565ec008a2e0927821c007e96d3a7/greenlet-3.3.2-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508c7f01f1791fbc8e011bd508f6794cb95397fdb198a46cb6635eb5b78d85a7", size = 602132, upload-time = "2026-02-20T21:02:43.261Z" }, - { url = "https://files.pythonhosted.org/packages/ad/55/9f1ebb5a825215fadcc0f7d5073f6e79e3007e3282b14b22d6aba7ca6cb8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad0c8917dd42a819fe77e6bdfcb84e3379c0de956469301d9fd36427a1ca501f", size = 591729, upload-time = "2026-02-20T20:20:58.395Z" }, - { url = "https://files.pythonhosted.org/packages/24/b4/21f5455773d37f94b866eb3cf5caed88d6cea6dd2c6e1f9c34f463cba3ec/greenlet-3.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:97245cc10e5515dbc8c3104b2928f7f02b6813002770cfaffaf9a6e0fc2b94ef", size = 1551946, upload-time = "2026-02-20T20:49:31.102Z" }, - { url = "https://files.pythonhosted.org/packages/00/68/91f061a926abead128fe1a87f0b453ccf07368666bd59ffa46016627a930/greenlet-3.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8c1fdd7d1b309ff0da81d60a9688a8bd044ac4e18b250320a96fc68d31c209ca", size = 1618494, upload-time = "2026-02-20T20:21:06.541Z" }, - { url = "https://files.pythonhosted.org/packages/ac/78/f93e840cbaef8becaf6adafbaf1319682a6c2d8c1c20224267a5c6c8c891/greenlet-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:5d0e35379f93a6d0222de929a25ab47b5eb35b5ef4721c2b9cbcc4036129ff1f", size = 230092, upload-time = "2026-02-20T20:17:09.379Z" }, - { url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" }, - { url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" }, - { url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" }, - { url = "https://files.pythonhosted.org/packages/9c/8b/1430a04657735a3f23116c2e0d5eb10220928846e4537a938a41b350bed6/greenlet-3.3.2-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4375a58e49522698d3e70cc0b801c19433021b5c37686f7ce9c65b0d5c8677d2", size = 605046, upload-time = "2026-02-20T21:02:45.234Z" }, - { url = "https://files.pythonhosted.org/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358", size = 594156, upload-time = "2026-02-20T20:20:59.955Z" }, - { url = "https://files.pythonhosted.org/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99", size = 1554649, upload-time = "2026-02-20T20:49:32.293Z" }, - { url = "https://files.pythonhosted.org/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be", size = 1619472, upload-time = "2026-02-20T20:21:07.966Z" }, - { url = "https://files.pythonhosted.org/packages/f1/3a/efb2cf697fbccdf75b24e2c18025e7dfa54c4f31fab75c51d0fe79942cef/greenlet-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e692b2dae4cc7077cbb11b47d258533b48c8fde69a33d0d8a82e2fe8d8531d5", size = 230389, upload-time = "2026-02-20T20:17:18.772Z" }, - { url = "https://files.pythonhosted.org/packages/e1/a1/65bbc059a43a7e2143ec4fc1f9e3f673e04f9c7b371a494a101422ac4fd5/greenlet-3.3.2-cp311-cp311-win_arm64.whl", hash = "sha256:02b0a8682aecd4d3c6c18edf52bc8e51eacdd75c8eac52a790a210b06aa295fd", size = 229645, upload-time = "2026-02-20T20:18:18.695Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, - { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, - { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, - { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, - { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, - { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, - { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, - { url = "https://files.pythonhosted.org/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f", size = 231081, upload-time = "2026-02-20T20:17:28.121Z" }, - { url = "https://files.pythonhosted.org/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643", size = 230331, upload-time = "2026-02-20T20:17:23.34Z" }, - { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, - { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, - { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, - { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, - { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, - { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, - { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, - { url = "https://files.pythonhosted.org/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124", size = 230961, upload-time = "2026-02-20T20:16:58.461Z" }, - { url = "https://files.pythonhosted.org/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327", size = 230221, upload-time = "2026-02-20T20:17:37.152Z" }, - { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, - { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, - { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, - { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, - { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, - { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, - { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" }, - { url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" }, - { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, - { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, - { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, - { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, - { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, - { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, - { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, - { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, -] - [[package]] name = "gremlinpython" version = "3.7.6" @@ -1903,18 +1549,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/55/f6adf83dd74563aca7721d456b1d33d7656448e29cc79a6aede3bb6ffa5b/gremlinpython-3.8.1-py3-none-any.whl", hash = "sha256:2e8136f9ea8cd771f9cc6f86f4ce73130595aed414a363534e1a4e18bfa81427", size = 75457, upload-time = "2026-04-07T00:22:18.776Z" }, ] -[[package]] -name = "hyperlink" -version = "21.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3a/51/1947bd81d75af87e3bb9e34593a4cf118115a8feb451ce7a69044ef1412e/hyperlink-21.0.0.tar.gz", hash = "sha256:427af957daa58bc909471c6c40f74c5450fa123dd093fc53efd2e91d2705a56b", size = 140743, upload-time = "2021-01-08T05:51:20.972Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/aa/8caf6a0a3e62863cbb9dab27135660acba46903b703e224f14f447e57934/hyperlink-21.0.0-py2.py3-none-any.whl", hash = "sha256:e6b14c37ecb73e89c77d78cdb4c2cc8f3fb59a885c5b3f819ff4ed80f25af1b4", size = 74638, upload-time = "2021-01-08T05:51:22.906Z" }, -] - [[package]] name = "idna" version = "3.18" @@ -1924,19 +1558,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] -[[package]] -name = "incremental" -version = "24.11.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ef/3c/82e84109e02c492f382c711c58a3dd91badda6d746def81a1465f74dc9f5/incremental-24.11.0.tar.gz", hash = "sha256:87d3480dbb083c1d736222511a8cf380012a8176c2456d01ef483242abbbcf8c", size = 24000, upload-time = "2025-11-28T02:30:17.861Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/55/0f4df2a44053867ea9cbea73fc588b03c55605cd695cee0a3d86f0029cb2/incremental-24.11.0-py3-none-any.whl", hash = "sha256:a34450716b1c4341fe6676a0598e88a39e04189f4dce5dc96f656e040baa10b3", size = 21109, upload-time = "2025-11-28T02:30:16.442Z" }, -] - [[package]] name = "iniconfig" version = "2.1.0" @@ -2844,42 +2465,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] -[[package]] -name = "pyopenssl" -version = "26.2.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version <= '3.9'", -] -dependencies = [ - { name = "cryptography", version = "47.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.9'" }, - { name = "typing-extensions", marker = "python_full_version <= '3.9'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1a/51/27a5ad5f939d08f690a326ef9582cda7140555180db71695f6fb747d6a36/pyopenssl-26.2.0.tar.gz", hash = "sha256:8c6fcecd1183a7fc897548dfe388b0cdb7f37e018200d8409cf33959dbe35387", size = 182195, upload-time = "2026-05-04T23:06:09.72Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/73/b8/a0e2790ae249d6f38c9f66de7a211621a7ab2650217bcd04e1262f578a56/pyopenssl-26.2.0-py3-none-any.whl", hash = "sha256:4f9d971bc5298b8bc1fab282803da04bf000c755d4ad9d99b52de2569ca19a70", size = 55823, upload-time = "2026-05-04T23:06:08.395Z" }, -] - -[[package]] -name = "pyopenssl" -version = "26.4.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", - "python_full_version >= '3.9.12' and python_full_version < '3.10'", - "python_full_version > '3.9' and python_full_version < '3.9.12'", -] -dependencies = [ - { name = "cryptography", version = "50.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.9'" }, - { name = "typing-extensions", marker = "python_full_version > '3.9' and python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3f/e8/7325d258199b159eb2c03fe32107533e2832e70e63f4fb88a6aa00023201/pyopenssl-26.4.0.tar.gz", hash = "sha256:28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7", size = 182046, upload-time = "2026-08-01T19:50:50.512Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/ad/2cf6d3fa2fae5c79e1ed9960c0d42badd0f94d81dd12b50604cdc839e648/pyopenssl-26.4.0-py3-none-any.whl", hash = "sha256:f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c", size = 56026, upload-time = "2026-08-01T19:50:48.94Z" }, -] - [[package]] name = "pytest" version = "8.4.2" @@ -3113,13 +2698,9 @@ dev = [ { name = "ccm" }, { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version < '3.10'" }, { name = "coverage", version = "7.15.3", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version >= '3.10'" }, + { name = "cryptography", version = "47.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.9'" }, + { name = "cryptography", version = "50.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.9'" }, { name = "cython" }, - { name = "eventlet", version = "0.40.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "eventlet", version = "0.41.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "futurist", version = "3.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "futurist", version = "3.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, - { name = "futurist", version = "3.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "gevent" }, { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, @@ -3131,8 +2712,6 @@ dev = [ { name = "pyyaml" }, { name = "setuptools", version = "82.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "setuptools", version = "83.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "twisted", version = "25.5.0", source = { registry = "https://pypi.org/simple" }, extra = ["tls"], marker = "python_full_version < '3.9.12'" }, - { name = "twisted", version = "26.4.0", source = { registry = "https://pypi.org/simple" }, extra = ["tls"], marker = "python_full_version >= '3.9.12'" }, ] [package.metadata] @@ -3152,10 +2731,8 @@ provides-extras = ["graph", "cle", "compress-lz4", "compress-snappy", "auth-kerb dev = [ { name = "ccm", git = "https://github.com/scylladb/scylla-ccm.git?rev=master" }, { name = "coverage", extras = ["toml"], specifier = ">=7.6" }, + { name = "cryptography", specifier = ">=42.0" }, { name = "cython", specifier = ">=3.2" }, - { name = "eventlet", specifier = ">=0.33.3" }, - { name = "futurist" }, - { name = "gevent" }, { name = "numpy" }, { name = "objgraph" }, { name = "packaging", specifier = ">=25.0" }, @@ -3163,21 +2740,6 @@ dev = [ { name = "pytest", specifier = "~=8.0" }, { name = "pyyaml" }, { name = "setuptools" }, - { name = "twisted", extras = ["tls"] }, -] - -[[package]] -name = "service-identity" -version = "26.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "cryptography", version = "47.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.9'" }, - { name = "cryptography", version = "50.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.9'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/61/87/ad52e2c582c0f0e7f0a1b86950494c38d67422dc0f5ed9044a5fb9569a49/service_identity-26.1.0.tar.gz", hash = "sha256:6358c52882c96e66ac4a55eb3a72c7dd4a70763f8cc6fa4e70abde2656f4bf3b", size = 42898, upload-time = "2026-05-30T12:04:55.184Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/93/eb/2433e1af4ff903499144de4846569fb3300b816179ae99a03c2f011b666a/service_identity-26.1.0-py3-none-any.whl", hash = "sha256:68c32dadbb69135fb951077677e07cd7f6031020f3a8c8f47a28cda8a0742118", size = 11370, upload-time = "2026-05-30T12:04:53.911Z" }, ] [[package]] @@ -3283,68 +2845,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" }, ] -[[package]] -name = "twisted" -version = "25.5.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version > '3.9' and python_full_version < '3.9.12'", - "python_full_version <= '3.9'", -] -dependencies = [ - { name = "attrs", marker = "python_full_version < '3.9.12'" }, - { name = "automat", marker = "python_full_version < '3.9.12'" }, - { name = "constantly", marker = "python_full_version < '3.9.12'" }, - { name = "hyperlink", marker = "python_full_version < '3.9.12'" }, - { name = "incremental", marker = "python_full_version < '3.9.12'" }, - { name = "typing-extensions", marker = "python_full_version < '3.9.12'" }, - { name = "zope-interface", version = "8.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9.12'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/13/0f/82716ed849bf7ea4984c21385597c949944f0f9b428b5710f79d0afc084d/twisted-25.5.0.tar.gz", hash = "sha256:1deb272358cb6be1e3e8fc6f9c8b36f78eb0fa7c2233d2dbe11ec6fee04ea316", size = 3545725, upload-time = "2025-06-07T09:52:24.858Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/66/ab7efd8941f0bc7b2bd555b0f0471bff77df4c88e0cc31120c82737fec77/twisted-25.5.0-py3-none-any.whl", hash = "sha256:8559f654d01a54a8c3efe66d533d43f383531ebf8d81d9f9ab4769d91ca15df7", size = 3204767, upload-time = "2025-06-07T09:52:21.428Z" }, -] - -[package.optional-dependencies] -tls = [ - { name = "idna", marker = "python_full_version < '3.9.12'" }, - { name = "pyopenssl", version = "26.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.9'" }, - { name = "pyopenssl", version = "26.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.9' and python_full_version < '3.9.12'" }, - { name = "service-identity", marker = "python_full_version < '3.9.12'" }, -] - -[[package]] -name = "twisted" -version = "26.4.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", - "python_full_version >= '3.9.12' and python_full_version < '3.10'", -] -dependencies = [ - { name = "attrs", marker = "python_full_version >= '3.9.12'" }, - { name = "automat", marker = "python_full_version >= '3.9.12'" }, - { name = "constantly", marker = "python_full_version >= '3.9.12'" }, - { name = "hyperlink", marker = "python_full_version >= '3.9.12'" }, - { name = "incremental", marker = "python_full_version >= '3.9.12'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.9.12'" }, - { name = "zope-interface", version = "8.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9.12' and python_full_version < '3.10'" }, - { name = "zope-interface", version = "8.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/db/97/6e9beb1e78247ae6dc34114f27d538cf2cb183c4afcd3609dfdf2b0439c8/twisted-26.4.0.tar.gz", hash = "sha256:dbfd0fe1ee409d0243fdd7a6a6ff14f4948cec1fd78e0376291f805e1501fae9", size = 3575095, upload-time = "2026-05-11T11:24:51.861Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/57/bcf4e2370dd218c9aa68a9140a65d86729c73f1d529f7e94786c2766fc72/twisted-26.4.0-py3-none-any.whl", hash = "sha256:dc25ea0ebf6511c24f03232ee9f4afa54b291c5d897990e3a39cc4d14a1ef4c0", size = 3230362, upload-time = "2026-05-11T11:24:49.5Z" }, -] - -[package.optional-dependencies] -tls = [ - { name = "idna", marker = "python_full_version >= '3.9.12'" }, - { name = "pyopenssl", version = "26.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9.12'" }, - { name = "service-identity", marker = "python_full_version >= '3.9.12'" }, -] - [[package]] name = "typing-extensions" version = "4.16.0" @@ -3430,103 +2930,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/a6/cc5f24b3f1a46a826b7e30ef56fdc1fe22315fef96de8e22afbdd5d98e7a/winkerberos-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:441884c0bda4bee0125fdbd7fee6a232dab58b4a64be8950eb17a8a7404a5440", size = 28715, upload-time = "2025-12-03T14:17:28.813Z" }, ] -[[package]] -name = "wrapt" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2b/b0/c1f5a970721f06b85c0cd5142e0ff8fe067708abd779b0c4f4be7d61d09f/wrapt-2.3.0.tar.gz", hash = "sha256:681a2d0eefd721998f90642762b8e75c2159ec531b20ad5e437245ea7b06a107", size = 131509, upload-time = "2026-07-28T06:06:14.895Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/40/31/5822ce37ca8820c2ed35a498c67c8b37960b9cee2ba437fd32849d0a234c/wrapt-2.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0bb2797048db0956348cb3058c33bc4184614f13231389cfbccc16a5d32780a7", size = 81191, upload-time = "2026-07-28T06:04:04.858Z" }, - { url = "https://files.pythonhosted.org/packages/7a/5a/3c6117938be98754578ab83f5a40d7d0ea2cd2c487dc5cd6027ee7228229/wrapt-2.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ce9f398f868d2b3b27aa2ea4de79645ef9077aeeac8dfc2814b0d542c6a2b87f", size = 82255, upload-time = "2026-07-28T06:04:07.151Z" }, - { url = "https://files.pythonhosted.org/packages/a5/0f/94ae724c5087eb6054c0d63febd7094947dcf302fe058e2e0488102a872b/wrapt-2.3.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ad71df7a04dd3497e9302e81f4a7c91bd401ea0e15a9df9029527900f94bee43", size = 155228, upload-time = "2026-07-28T06:04:08.272Z" }, - { url = "https://files.pythonhosted.org/packages/6c/21/1f780bba935dcf697c0c59de9be3a559bbb8e31a53ca3f25422023738432/wrapt-2.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fc82c2ccc8e234c844f5303d9f2984b346dcdd53e94823ce8420d2c75b4b9023", size = 157073, upload-time = "2026-07-28T06:04:09.459Z" }, - { url = "https://files.pythonhosted.org/packages/73/31/6c7799d7b6431fcd7e1b83245fb45258a2d2c3a2187fbaecb83572a72d7a/wrapt-2.3.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6e19531ae33c508cea7d84a7edfda01fa86e51b8d1a93a77712c55e6e469152", size = 151594, upload-time = "2026-07-28T06:04:10.784Z" }, - { url = "https://files.pythonhosted.org/packages/ce/17/42d670dbfafd49076c6eb2b7d67633d7e1c968e39bfb11a135acb6fac67b/wrapt-2.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:df4ce31150bcd5d9f36f816aac3010ab4f4bf8672ac1d3b0ac7d539ec61c7c02", size = 156069, upload-time = "2026-07-28T06:04:12.316Z" }, - { url = "https://files.pythonhosted.org/packages/1f/d6/c66b4ba4eda49257c84d5c2df26118280f09ca7905aee20d0064db778d13/wrapt-2.3.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e2e692bc0d63f881cf7006730a56bd4e0c2fab5dc318466942805d692b166276", size = 150930, upload-time = "2026-07-28T06:04:13.482Z" }, - { url = "https://files.pythonhosted.org/packages/c0/f2/1a3b949c0322fb27396eafd1044328c1cb0400e0b32105d75a3cd03096e7/wrapt-2.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c8388ba7faf5dbf9ee106bb70d66f257629b1bd98091123e19e8a4553a319199", size = 154525, upload-time = "2026-07-28T06:04:14.698Z" }, - { url = "https://files.pythonhosted.org/packages/12/65/147563a3dfa6e830c857b93b530ebd8c0cd9d540e5914aec8f9b12880c02/wrapt-2.3.0-cp310-cp310-win32.whl", hash = "sha256:e045ff75d7d94900fc32896ed93c45ce2d2cac28c9dead582ff9a5a49d446e35", size = 77879, upload-time = "2026-07-28T06:04:16.102Z" }, - { url = "https://files.pythonhosted.org/packages/c4/eb/921405b4dc55d4f8be4c700ef120539fdd75d5fdb50d83bd257171ee18e0/wrapt-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:b4fc96b159af0a3e0faa72475a69d66292bea72a5bed1e1aca1bffbddc3cb2b0", size = 80733, upload-time = "2026-07-28T06:04:17.43Z" }, - { url = "https://files.pythonhosted.org/packages/b6/13/75947450c5bb57795fa86384721cd52c5c4deb0879022f309501a8a85d44/wrapt-2.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:1236fa25173ca964c97422470482e9011b9e3c7ed0d75798b40b3da3b0e0e760", size = 80199, upload-time = "2026-07-28T06:04:18.761Z" }, - { url = "https://files.pythonhosted.org/packages/00/b8/9182e4c618a847be0baccb68e4602b070d0fa22c782cf058f4bc66b32709/wrapt-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5ab559e1b2551d23d54db2a0001c6d73bad022a254639561c5f6c382a9d6c2fe", size = 81427, upload-time = "2026-07-28T06:04:20.106Z" }, - { url = "https://files.pythonhosted.org/packages/84/ca/613cefd9c5977366b1587e61c0b428176d382e6d75b454084c5e58503042/wrapt-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bff9a671bc00709cab5a7f745c592b5671873449db0ee2a569af994f16b29a4d", size = 82360, upload-time = "2026-07-28T06:04:21.613Z" }, - { url = "https://files.pythonhosted.org/packages/71/71/4cd2151a236f44a6e2dd4ed8011838d7ba0be3d656c8bafdfc65a2ed1917/wrapt-2.3.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fc648a335d7e01adb3640b25f02fd0ea05886cf04d0af7f4ee902bc7b5e466e8", size = 161700, upload-time = "2026-07-28T06:04:22.723Z" }, - { url = "https://files.pythonhosted.org/packages/49/2c/bc508fee75eb2919ed69769800b09968e4aab16897f909a23f39c81e323f/wrapt-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0077f3d65541925fa83002f967b22ad6550d24813ac64cb905f717194128d9c", size = 162922, upload-time = "2026-07-28T06:04:24.177Z" }, - { url = "https://files.pythonhosted.org/packages/4d/e5/04f34d38e66d857dfc2fc4088d60e70c0e422467822defa49b2b4a26e17b/wrapt-2.3.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9790ea25190a4e0fe4cdf4eeb868e9d75f8a024a70a5b6bf9c348a3a2b72e731", size = 156125, upload-time = "2026-07-28T06:04:25.58Z" }, - { url = "https://files.pythonhosted.org/packages/23/41/c35940ea1c423f129ebe4361db853bc80d4def6326242e1206fa15bf94f4/wrapt-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:816877aa749253149f9ecfd2635d4d948ecfa338e1a0311d187b1acb1bb8a3eb", size = 162039, upload-time = "2026-07-28T06:04:27.154Z" }, - { url = "https://files.pythonhosted.org/packages/0e/60/9bda34c3d7d182aa703fe35339ae0ed4c4dad5e5c587f93890143e1f87fb/wrapt-2.3.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3d1c2c1b808600d2ea808e6360910a60ed5f409a4011655e10f9164ba0a414a6", size = 155110, upload-time = "2026-07-28T06:04:28.497Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ba/60bfd9b1a751f4fcb2d603668fc272d651ccdd339a56acf8c40ad21a0293/wrapt-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5ba1e5e08ddc46130e9682b2c249f2d1dd39bda9106ed4bd401b7519f18f41bd", size = 161089, upload-time = "2026-07-28T06:04:29.959Z" }, - { url = "https://files.pythonhosted.org/packages/0f/32/2bd358c6f4f1305c813479d1e9ba746bebdd794f4a20107ab2b3ee0cbd45/wrapt-2.3.0-cp311-cp311-win32.whl", hash = "sha256:45c9279b373d15649dfa2c2077cb3408ea1a6d3125afbdab9d6b809a66f68e14", size = 78030, upload-time = "2026-07-28T06:04:31.241Z" }, - { url = "https://files.pythonhosted.org/packages/4a/62/ecc969b13b141fef89b888c9760821cb01a86ac8fc953911592c8e1e1522/wrapt-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:195b1842b4122fb54e3cd3dd5b2b4aa49302a5a61da901df0481f5c97aedde84", size = 80944, upload-time = "2026-07-28T06:04:32.655Z" }, - { url = "https://files.pythonhosted.org/packages/c4/3d/9278ada8a2b3f24372b630361e84e9a7de7abc3784634860c26d1c37785a/wrapt-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:6db604ef0c67bdb2042ecdfd7b7f037cf09733557ca42360d1018285634f7b98", size = 80074, upload-time = "2026-07-28T06:04:33.811Z" }, - { url = "https://files.pythonhosted.org/packages/5b/4a/d17a0fad1bf1c5f2c887ff71fef75654141b0880bff71d157d955b5bec3a/wrapt-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0a45ffae742ce91a16e11cb6c7cd71e7f9994f3cbd283b962ab093f5c6dcf525", size = 82139, upload-time = "2026-07-28T06:04:35.082Z" }, - { url = "https://files.pythonhosted.org/packages/6e/55/51b92daaf6defb57f4dc56bdcce985400f75c6984a03ca5e78ccac717028/wrapt-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:69e477046f2237ef0bc6547544ee73008dc764ca26eff44f09e976d221b34d5d", size = 82723, upload-time = "2026-07-28T06:04:36.502Z" }, - { url = "https://files.pythonhosted.org/packages/28/7f/cfd9bc4b1f5e424eeea83d0493e43f3b1b02707ce8e50c47945873982bd5/wrapt-2.3.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d221a6e6ddd302b8397433184e96b59f259f50024b854db1c411a881586b6b8", size = 172381, upload-time = "2026-07-28T06:04:37.674Z" }, - { url = "https://files.pythonhosted.org/packages/cb/89/ff7814f6eb6856b479946117d1138a2fbb46cdb6b1f379db359056c69743/wrapt-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:392158c9a7f2ab1b8699418bfc0fe6f83548788c418b27d7bf2019ad3405cebb", size = 174120, upload-time = "2026-07-28T06:04:38.987Z" }, - { url = "https://files.pythonhosted.org/packages/12/1e/8eded8615d39e3ce81f626937a3a87b280a2a86239a2bf14a4b4bb345034/wrapt-2.3.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e5301c35cf75655eb33498f2bd6ae8703ca19940e3167dc9cdf740c712a39c60", size = 163035, upload-time = "2026-07-28T06:04:40.361Z" }, - { url = "https://files.pythonhosted.org/packages/35/ea/a0af2d9da62897af2a055484920de05dade30d2ba2c0d65cbdea875d3d8b/wrapt-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:418f54bb09d1762db02c7009b4051149893af3153a87f92d70356703c11eea02", size = 171887, upload-time = "2026-07-28T06:04:41.614Z" }, - { url = "https://files.pythonhosted.org/packages/7e/dd/63cd4c864c65ef4906df64bd2d378f4a62b54f28063f282dfb3bf93caead/wrapt-2.3.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1598becd30f8f2777d18564064eb4f4dbe1ab0e05a8f09786d0ef505ac782bf3", size = 161113, upload-time = "2026-07-28T06:04:42.864Z" }, - { url = "https://files.pythonhosted.org/packages/ca/ee/82f1fc9e431b5c2c5a6d201aa865dbeae3984c311c6d11a185f0c8367cf6/wrapt-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3da470536bf9645143323dd41b32db55c6f4304ad382094c1a1da8a92061e10d", size = 170530, upload-time = "2026-07-28T06:04:44.212Z" }, - { url = "https://files.pythonhosted.org/packages/37/a5/5dc590e863a419930d988f8b7ca3e75a6befcfb10b6003b3a152f3d5f732/wrapt-2.3.0-cp312-cp312-win32.whl", hash = "sha256:fb8e2e6704a1e0b1b989546c69e2688371ef4a07fa5f61bde3eb6211186f5ac1", size = 78323, upload-time = "2026-07-28T06:04:45.484Z" }, - { url = "https://files.pythonhosted.org/packages/51/f9/4a6925a07951df56394f7e6ebe14f69f1c5ef9d87aa63e0839acf15aa63a/wrapt-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:cdc021cb0b62471d6aac7f2bd92f3b4658073775f9ee7fcd325c511129e7bcc8", size = 81180, upload-time = "2026-07-28T06:04:47.021Z" }, - { url = "https://files.pythonhosted.org/packages/a8/4f/8b5de0395b2a72216751d41c9861df6facaeb611b619d8810ed2b3b23eb2/wrapt-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:67bfe2485f50368c3fcd2275fc1fd100e350d601e0058921a7c82678a465aeab", size = 80155, upload-time = "2026-07-28T06:04:48.373Z" }, - { url = "https://files.pythonhosted.org/packages/8e/6e/0f88a072483e76b881e3fdcd6b6ffb4a5791002514fe541e72b1b73c859a/wrapt-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d3fb71e65b001adfc42684522eeccd9c21d8ba679945abc993439567b66e59f", size = 81960, upload-time = "2026-07-28T06:04:49.622Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ff/b7e2776e7c294075eb712cc9ef573d1b818f393006d09787262b8fc871c4/wrapt-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:51a7a4181c1295774812271fbcd7c909df372bc25579d4ed9eb875caaf0ae86f", size = 82435, upload-time = "2026-07-28T06:04:50.9Z" }, - { url = "https://files.pythonhosted.org/packages/d8/90/343bb5d0f1f9669bc252a6073f085b4abf862511bd5c9c9eaec754341f1d/wrapt-2.3.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9045917809c63fdf7abe3a2ceaed3d670b8ee4500ddd9291192d30aeb34467c5", size = 170350, upload-time = "2026-07-28T06:04:52.187Z" }, - { url = "https://files.pythonhosted.org/packages/59/f8/13b79a392930bd0dd6b86cbfbfe1c40944110456e1dc6d809e5c46ece904/wrapt-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54ca1d5573f69b5fe1d74f1f65799c68015e82f685efec9fd8cfa40a094c44d0", size = 170022, upload-time = "2026-07-28T06:04:53.599Z" }, - { url = "https://files.pythonhosted.org/packages/b2/fc/4f1b6918f5290db959d6e0c07f77385d87cede29c39c9cf8f145e9c82954/wrapt-2.3.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:242b60c21e30866e6a2fa606c612b47c553fa60c0eaeeeb7797fb842ac0ce609", size = 161043, upload-time = "2026-07-28T06:04:54.936Z" }, - { url = "https://files.pythonhosted.org/packages/01/e1/45d3cf74414780bdff6d0380467e003f6eb0f028b6c9403db868dbc7209c/wrapt-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3f3d7ec0a51fbfe00d3aef047641ff2c58b25565b4717fc1f90e050be01cba8", size = 168576, upload-time = "2026-07-28T06:04:56.261Z" }, - { url = "https://files.pythonhosted.org/packages/f3/73/2fa58dd97f191c997755e2c6d569a68f0c433db4e4b36099bdd7227b6cac/wrapt-2.3.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:261f53870cd4fb2bf38f9f972c56c728fd224cb7c65721307de59d9e7e6741ae", size = 159140, upload-time = "2026-07-28T06:04:57.754Z" }, - { url = "https://files.pythonhosted.org/packages/29/a8/08a56e2000a8816d449dcbad8c8b081697acbbd490821ceca0f9d8e8d20c/wrapt-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8159ec0b0cb7608175eb150de94c19e34f4d47ac655f5ca9baf45df6b688ffd3", size = 169263, upload-time = "2026-07-28T06:04:59.161Z" }, - { url = "https://files.pythonhosted.org/packages/9e/d4/354e1725e35a73b2af4fa70a3e024c7a5d1bf1802dfb862dcb668aae0253/wrapt-2.3.0-cp313-cp313-win32.whl", hash = "sha256:10461884b3014fbfc8eb7d09a93c5f246363e6711d9d881f95eb8c27fdef049f", size = 78241, upload-time = "2026-07-28T06:05:00.507Z" }, - { url = "https://files.pythonhosted.org/packages/6c/7e/34c87fa2174848dfee820322aaa318bab08913998ccecc8d2f57b4ad4639/wrapt-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:ac870cc97b73bb00ac353329e9559a4bebc47c4c86792ed9b23b58c15b6ad838", size = 81113, upload-time = "2026-07-28T06:05:01.839Z" }, - { url = "https://files.pythonhosted.org/packages/11/86/fcc9a530579e008c9478bb565a6cdfbfd33536660f069c8b91a6607c5050/wrapt-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:a65e8db2b4e90c2e7ade931086351c98ef420bf7a94ee08c95ac8a3cbbc43579", size = 80182, upload-time = "2026-07-28T06:05:03.152Z" }, - { url = "https://files.pythonhosted.org/packages/96/50/3864848b95b28ef73e17551fc8dccbff2628a834f52cf26a57f9c419fb83/wrapt-2.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:fd1f2f557dd3491fe75905e578f4db967393d40d1a8f468edc4d40ac7f2d5944", size = 83921, upload-time = "2026-07-28T06:05:04.476Z" }, - { url = "https://files.pythonhosted.org/packages/3b/4c/3d1921a60c3e8c71c540ff136e6a47a1fbccf7f671e818394889f7871d9c/wrapt-2.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9f5d2aec29dfc76c37e23897dee92766a3fd4f3bff3ae7fc9c6b4bf37d8c1360", size = 84412, upload-time = "2026-07-28T06:05:05.921Z" }, - { url = "https://files.pythonhosted.org/packages/fa/1a/4a796ff7adb26ada6d4b758c94d47a38320b085e7099afc088efbbcdb006/wrapt-2.3.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:646d20d413ffcd1b0a2f700076e2d0252d872dcb7754860a73e45a59ea883614", size = 207168, upload-time = "2026-07-28T06:05:07.256Z" }, - { url = "https://files.pythonhosted.org/packages/1d/3e/d7777776806c579b761bac2f91721dda9f04c7a1b380213c5935cc750ae6/wrapt-2.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:379f670f45b7bb8993edd9f6fc36c6cc65edb81cffa0b504be34acb0303fff0a", size = 214351, upload-time = "2026-07-28T06:05:08.945Z" }, - { url = "https://files.pythonhosted.org/packages/63/27/2d64d394df7bf181955b3bb562bf33c4492fb4be113f53071106d43ad8b5/wrapt-2.3.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6208f302f110295d64b22a7ac96500c791bf492dce4366e622e4912b077c9687", size = 199020, upload-time = "2026-07-28T06:05:10.418Z" }, - { url = "https://files.pythonhosted.org/packages/3e/3d/fb31d3db7d9834d265fb1a27a2adf0ddf51557c67458c97b22439ad6ae3d/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ed635a9ca4f3a5a2b900c10c69e823373bc00ebc114b459383596d3487da3570", size = 209969, upload-time = "2026-07-28T06:05:11.983Z" }, - { url = "https://files.pythonhosted.org/packages/1f/d1/8724b5da582e62070dc9bf4d8bf1972f317297eefd7ba1f2b5c6393ccf6c/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:e3b9eaa742ae7a0aaaaad4ca4b69469d757af2d6e6663ef1dadc47adec0aeb41", size = 196324, upload-time = "2026-07-28T06:05:13.557Z" }, - { url = "https://files.pythonhosted.org/packages/0d/5c/3d9ef411149543016ee6bcf3af707f787cebd946527452b94bf122e9b7b4/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d0f7284f88f4833705132d06d3b425a43095c2cbd07c58166aac3ab646ba12a4", size = 202610, upload-time = "2026-07-28T06:05:15.048Z" }, - { url = "https://files.pythonhosted.org/packages/13/9b/4fc042ceb757866dd4a5fc057b3b736f2b360d3703ce9f830d83dc9226e0/wrapt-2.3.0-cp313-cp313t-win32.whl", hash = "sha256:7ebb274aba688b043429eb1500ff8a76ce0cb8ac0812ca3e301f06247b8722b3", size = 79178, upload-time = "2026-07-28T06:05:16.469Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ff/b94878f8eed809ca042685276bcea9f24e8c2ca7c9653bb80bbb920a68a5/wrapt-2.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c4bded758ad6f03b965830944a2f0bc5b2eb3767fe5a7310134315d1a6610e98", size = 82634, upload-time = "2026-07-28T06:05:18.026Z" }, - { url = "https://files.pythonhosted.org/packages/80/fb/663e1de5332a71685a729754312d327d4cada767c36e1c5a2db4c8de49e6/wrapt-2.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:d2cc64539da63e39ffb9c7ede849b6e8ddaaf7b3876b5cfb04efd85a5f3f4eb6", size = 81387, upload-time = "2026-07-28T06:05:19.417Z" }, - { url = "https://files.pythonhosted.org/packages/58/10/b073beaea89bc0d3670a75ff51139430a54b6af7ba7796507730634536dd/wrapt-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea52a0d0f08c584943d5764be0e84efa912c8da23c23e1e285ff2f5641c18fcc", size = 81978, upload-time = "2026-07-28T06:05:21.133Z" }, - { url = "https://files.pythonhosted.org/packages/b3/31/0916d9cebf848ed3f1a0c1888faee421747df77331e4db2bc527a9a85988/wrapt-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd85b0aa88efdb189d6ae2f35f4526943a8f091c38599c9c31478241c819e6a1", size = 82518, upload-time = "2026-07-28T06:05:22.562Z" }, - { url = "https://files.pythonhosted.org/packages/f5/73/31c1bf0f3384062751c2094dadb314916d70aa9b6bfd26d994b4a7b393fa/wrapt-2.3.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:141ed6211286a9660d8d6702de598b43f0934b4f0eda16393f100a80f501d945", size = 170187, upload-time = "2026-07-28T06:05:23.904Z" }, - { url = "https://files.pythonhosted.org/packages/ed/25/fce087d54b79b8905f3c3c9dd5f454bbd8d8acb80b960c4a6aee5b4659b3/wrapt-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e49885a62ec4ee854d1b9e6371fda6afd219917225752abf729a3f36d4df9a5", size = 169288, upload-time = "2026-07-28T06:05:25.378Z" }, - { url = "https://files.pythonhosted.org/packages/c7/30/0d09e6dddc6b7a7230ac77f50254b5980ab4fcd22976f72f8cc8a0404458/wrapt-2.3.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d6159c9b2fefec02314e1332dbbbfaf960e369dfd26bcf7f8b258b5732065b3", size = 160932, upload-time = "2026-07-28T06:05:27.022Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ca/0913af0d2ec0c43865d32d615f518fea66c13c5c930e489e9b0de248e9a8/wrapt-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:24da48596326ef8e448cfa837b454f638713d3531262375f00e5a9681682fc07", size = 169017, upload-time = "2026-07-28T06:05:28.501Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f2/3d1e47ea81b822210f5df1bf942fd90780a75c055243d569b664529dea88/wrapt-2.3.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cd3a2edf0427013736b8127955cec62608c56e53ea47e82812ea32059cda407f", size = 159065, upload-time = "2026-07-28T06:05:30.01Z" }, - { url = "https://files.pythonhosted.org/packages/43/a5/ef2066ced8e5fca204e2b361e9708e36555b40949c583d997ea3b590817d/wrapt-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fa0df3bff4e7ce45759f33fd39335fe2f60477bb9ecf7b8aa41e7d07ee36a23", size = 168821, upload-time = "2026-07-28T06:05:31.649Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e1/016104650d4e572fa91506eb396b3dd8efbccc9284fdc1c9479c3d21db28/wrapt-2.3.0-cp314-cp314-win32.whl", hash = "sha256:2935d5454b3f179a29b12cf390ee47246740ba2c3a7545b1b46ba31a5f2a4a0b", size = 78700, upload-time = "2026-07-28T06:05:33.391Z" }, - { url = "https://files.pythonhosted.org/packages/3d/97/6fdc20a9f2ca304748b3f0819cbf377d55260562777bf0b615431bc3c181/wrapt-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:cc2cea812e5cb179a796b766747e7d3b21088760d8deb95676d482b8c8e6fa7d", size = 81422, upload-time = "2026-07-28T06:05:34.774Z" }, - { url = "https://files.pythonhosted.org/packages/5e/a4/9cbd53bf05746bea2c392af39cb052427a8ec95cbd494d930733d8f44681/wrapt-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:22cc5c0a717bd4da87018ae0bffd4c19c6fb679d3ff357216ba566ab26c76cab", size = 80639, upload-time = "2026-07-28T06:05:36.228Z" }, - { url = "https://files.pythonhosted.org/packages/43/bb/6c5e4a0f66ea0d2b2dd267e8dd05a0014eea56840b3c8595d40b0a5d1f91/wrapt-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a6b5984cd65dd639546f0eb4b8eacf1c31cb2fe9fb5c27bffe240987cdb2cf84", size = 84030, upload-time = "2026-07-28T06:05:37.714Z" }, - { url = "https://files.pythonhosted.org/packages/6a/eb/a1aedf03283bc9cbf8a1783995ddc54e3c5a86878f19002d2c428494f4c5/wrapt-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c88abcf53daef80e01a75c7530e727fa6e2c1888fe83e3dcdba4c96216a1f5c7", size = 84419, upload-time = "2026-07-28T06:05:39.131Z" }, - { url = "https://files.pythonhosted.org/packages/63/61/50d511c0dc5105563849e86daa3e16ac7feef699f79fb05af45ea70107d5/wrapt-2.3.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:85de890ff968196e92dd1ae73a9fb8970495e7650a457b1c9ef0ac3dd550bce2", size = 207171, upload-time = "2026-07-28T06:05:40.69Z" }, - { url = "https://files.pythonhosted.org/packages/3f/59/9b538cf7795217e810699d16bc88b96a830d9b5c403eb2ec2db6b5f2ae81/wrapt-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50f416b74d092bb9f41b424e90dd457f365f7ba4b11de62a23679769a21bd85c", size = 214329, upload-time = "2026-07-28T06:05:42.287Z" }, - { url = "https://files.pythonhosted.org/packages/b3/28/9935d62b1499e5c8b3d191e99ba4eb31ca237a0b699142011a837e9dc7ea/wrapt-2.3.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39febbee6d77301d31da6996b152ce52452da7c7ef72aba10c2fa976dff9c295", size = 199079, upload-time = "2026-07-28T06:05:43.958Z" }, - { url = "https://files.pythonhosted.org/packages/2b/01/4446b80fa2ffa47a3449b250d004ba1c1937f07f64a179608fec735df866/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:93513bec052c6cd987f9f580c3df068c8bc4ebae6543736be3ca7ec5959cafcd", size = 209992, upload-time = "2026-07-28T06:05:45.677Z" }, - { url = "https://files.pythonhosted.org/packages/d4/07/56f26c9f9979586a021e8148747004aba4498f49458c90b0502969b904e1/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:729126e667da34d251b8ebf8a45ef0c5ddadc21542b3d6e1abf4259ece6508df", size = 196334, upload-time = "2026-07-28T06:05:47.608Z" }, - { url = "https://files.pythonhosted.org/packages/8b/41/6d7bcc895b0f28b2250e10908f060687b9165429dcd7f22ddb3d4c031b74/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:626b69db2021aa01671ec7bbc9740e558522bd44c18cf2ce69bf3d666a014109", size = 202644, upload-time = "2026-07-28T06:05:49.183Z" }, - { url = "https://files.pythonhosted.org/packages/cd/25/7860927edba06b758b8852a6f02e832be715563c67a6795d94350bc81099/wrapt-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:629d73378082c00a8173031f9fb30a3ac6abbc894a5bfdfae71fabc60642d501", size = 79685, upload-time = "2026-07-28T06:05:50.976Z" }, - { url = "https://files.pythonhosted.org/packages/c4/0f/270bafe92fde3b069a39bc01e39ee79340895b335640df861d43d2a51885/wrapt-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:42869085687f0aefd57c0f636c3f9354f8ffb321a8ba9cb52d19beb796e561c5", size = 83104, upload-time = "2026-07-28T06:05:52.405Z" }, - { url = "https://files.pythonhosted.org/packages/55/b3/af176d79a8515a8a720eccdad9a96f6e31a30abf2865430c8c42adf2fd13/wrapt-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b1e5aa486e269b00ed35e64771c7d0ab8096cfd2643405ca8cd60ebedc099a51", size = 81774, upload-time = "2026-07-28T06:05:53.902Z" }, - { url = "https://files.pythonhosted.org/packages/40/99/b44e9dc20c8d768ffe65174bfebde1412068fc1638aac436eccf1e7a603a/wrapt-2.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c3b476ae63b4a3b4da681aafcb25ff3542d289fbda8b5da7caf76aaffafafdbb", size = 81227, upload-time = "2026-07-28T06:05:55.438Z" }, - { url = "https://files.pythonhosted.org/packages/7e/cb/1e1bbdb39ea166b4b2568c5eec3d82f59cddecf1ed57e5c4a1ed54692107/wrapt-2.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:932dced0a7b2950ed58a3325536a1dcb7b58e7330af54e8552d2e566b5328b99", size = 82284, upload-time = "2026-07-28T06:05:56.879Z" }, - { url = "https://files.pythonhosted.org/packages/54/0a/935b037716e02376415dbc9fe95e523c64111c223a0de4dc2e12c1e1ee20/wrapt-2.3.0-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0db083387d6e75ec0be8173ecbf0e811cf60bae1cc75a815feb104167ea10d4d", size = 154975, upload-time = "2026-07-28T06:05:58.369Z" }, - { url = "https://files.pythonhosted.org/packages/6e/ed/6222b5e4ab73a0185d77e3490dbdd372dc1cd961acbcdc62b0bc345a8d2c/wrapt-2.3.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abc71504669d126d91f89fc0e388c6295d8fbd2439be884f175133fda8aa403c", size = 157056, upload-time = "2026-07-28T06:06:00.038Z" }, - { url = "https://files.pythonhosted.org/packages/20/f8/eac651ecc80db2c7ac697111411de05fd4d4f9eea557d32b9bb11e1ada5c/wrapt-2.3.0-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b767a9566f165dd14decf8f4194c6bb0ce3a8420cec213824e05a99400c9260a", size = 151513, upload-time = "2026-07-28T06:06:01.808Z" }, - { url = "https://files.pythonhosted.org/packages/e1/32/ed810ea37c2b4b9948bf23def5954a1848d98021602dd7220db3f8ee1a58/wrapt-2.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:73d0b10b64620a2cf4bc3d31775c4d9527e309a5549e4379e3bf71e8d2dc193e", size = 156054, upload-time = "2026-07-28T06:06:03.467Z" }, - { url = "https://files.pythonhosted.org/packages/7f/8e/facfaa9b2d4eda4f14fb5f88fc493947d1513cec28538613082e1663037d/wrapt-2.3.0-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:e31734c5077f29f892b2565eee5106d610278151ad49fc6a9d69a647cd5730e2", size = 150821, upload-time = "2026-07-28T06:06:05.152Z" }, - { url = "https://files.pythonhosted.org/packages/5a/7e/14f4b6f2d9a89186f35f04dd1ec6aad41ffa439dac16c88fc41f3756b9a6/wrapt-2.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:628f3ba8ec793a5b10a6cd8c6c6b7b55eb552abd1f3bd301336acb74c7a82dfe", size = 154303, upload-time = "2026-07-28T06:06:06.808Z" }, - { url = "https://files.pythonhosted.org/packages/4b/90/e25fd18051bc83a1f7c62edd184a262a3f593716126619ebbc79de801e6a/wrapt-2.3.0-cp39-cp39-win32.whl", hash = "sha256:3873c3c5ca9f4ef91f693602eca19d1f1e7c410338df82a4ff11d826b5896a8f", size = 77908, upload-time = "2026-07-28T06:06:08.384Z" }, - { url = "https://files.pythonhosted.org/packages/eb/2c/40f523565f1aed94d0030f4f99f481adc5b5620ee8600bfcd46effb49a79/wrapt-2.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:8f8a1c6472675956cece9a8f403f43c3594f1681319eed2dd56f60877397c636", size = 80796, upload-time = "2026-07-28T06:06:09.906Z" }, - { url = "https://files.pythonhosted.org/packages/3f/91/a86501de81265751a42b3c3f977e6c88d580936dd180e3298e2bd813e0d4/wrapt-2.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:c8858d8ff9822a081e3cc49ae1b3b22f0f789c14001cdac8f94564010d9c9d66", size = 80227, upload-time = "2026-07-28T06:06:11.413Z" }, - { url = "https://files.pythonhosted.org/packages/00/39/3daf9f47be208606586de4568ba6713db53ebc8fd7a575aea1fe57983b69/wrapt-2.3.0-py3-none-any.whl", hash = "sha256:d8c7ed08477429752b8c44991f40ad7838b18332a160698740a6bfbc10d998a2", size = 61866, upload-time = "2026-07-28T06:06:12.9Z" }, -] - [[package]] name = "yarl" version = "1.22.0" @@ -3794,130 +3197,3 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bf/f4/ed5c402ac8fde4403ed3366c2716bfddc8a6677ebd59f3d62772cc7fe468/yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688", size = 97222, upload-time = "2026-07-20T02:07:41.55Z" }, { url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" }, ] - -[[package]] -name = "zope-event" -version = "6.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.9.12' and python_full_version < '3.10'", - "python_full_version > '3.9' and python_full_version < '3.9.12'", - "python_full_version <= '3.9'", -] -dependencies = [ - { name = "setuptools", version = "82.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c2/d8/9c8b0c6bb1db09725395618f68d3b8a08089fca0aed28437500caaf713ee/zope_event-6.0.tar.gz", hash = "sha256:0ebac894fa7c5f8b7a89141c272133d8c1de6ddc75ea4b1f327f00d1f890df92", size = 18731, upload-time = "2025-09-12T07:10:13.551Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/b5/1abb5a8b443314c978617bf46d5d9ad648bdf21058074e817d7efbb257db/zope_event-6.0-py3-none-any.whl", hash = "sha256:6f0922593407cc673e7d8766b492c519f91bdc99f3080fe43dcec0a800d682a3", size = 6409, upload-time = "2025-09-12T07:10:12.316Z" }, -] - -[[package]] -name = "zope-event" -version = "6.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] -sdist = { url = "https://files.pythonhosted.org/packages/93/41/faa10af34d48d9cd6fa0249a1162943ad84a9590bd1a06939981e6640416/zope_event-6.2.tar.gz", hash = "sha256:b97d5d6327067ee6b9dfcbdf606ade9ade70991e19c162e808ea39e5fcf0f8d3", size = 18958, upload-time = "2026-04-28T06:24:10.578Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/33/848922889e946d4befc415c219fe516af75c49555d8e736e183bfd30db42/zope_event-6.2-py3-none-any.whl", hash = "sha256:5e755153ac4faf64c10a4b6dd3307680166a3edf65b38df22df592610f8fa874", size = 6525, upload-time = "2026-04-28T06:24:09.176Z" }, -] - -[[package]] -name = "zope-interface" -version = "8.0.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.9.12' and python_full_version < '3.10'", - "python_full_version > '3.9' and python_full_version < '3.9.12'", - "python_full_version <= '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/88/3a/7fcf02178b8fad0a51e67e32765cd039ae505d054d744d76b8c2bbcba5ba/zope_interface-8.0.1.tar.gz", hash = "sha256:eba5610d042c3704a48222f7f7c6ab5b243ed26f917e2bc69379456b115e02d1", size = 253746, upload-time = "2025-09-25T05:55:51.285Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/75/e5/ffef169d17b92c6236b3b18b890c0ce73502f3cbd5b6532ff20d412d94a3/zope_interface-8.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fd7195081b8637eeed8d73e4d183b07199a1dc738fb28b3de6666b1b55662570", size = 207364, upload-time = "2025-09-25T05:58:50.262Z" }, - { url = "https://files.pythonhosted.org/packages/35/b6/87aca626c09af829d3a32011599d6e18864bc8daa0ad3a7e258f3d7f8bcf/zope_interface-8.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f7c4bc4021108847bce763673ce70d0716b08dfc2ba9889e7bad46ac2b3bb924", size = 207901, upload-time = "2025-09-25T05:58:51.74Z" }, - { url = "https://files.pythonhosted.org/packages/d8/c1/eec33cc9f847ebeb0bc6234d7d45fe3fc0a6fe8fc5b5e6be0442bd2c684d/zope_interface-8.0.1-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:758803806b962f32c87b31bb18c298b022965ba34fe532163831cc39118c24ab", size = 249358, upload-time = "2025-09-25T05:58:16.979Z" }, - { url = "https://files.pythonhosted.org/packages/58/7d/1e3476a1ef0175559bd8492dc7bb921ad0df5b73861d764b1f824ad5484a/zope_interface-8.0.1-cp310-cp310-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f8e88f35f86bbe8243cad4b2972deef0fdfca0a0723455abbebdc83bbab96b69", size = 254475, upload-time = "2025-09-25T05:58:10.032Z" }, - { url = "https://files.pythonhosted.org/packages/bc/67/ba5ea98ff23f723c5cbe7db7409f2e43c9fe2df1ced67881443c01e64478/zope_interface-8.0.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7844765695937d9b0d83211220b72e2cf6ac81a08608ad2b58f2c094af498d83", size = 254913, upload-time = "2025-09-25T06:26:22.263Z" }, - { url = "https://files.pythonhosted.org/packages/2b/a7/b1b8b6c13fba955c043cdee409953ee85f652b106493e2e931a84f95c1aa/zope_interface-8.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:64fa7b206dd9669f29d5c1241a768bebe8ab1e8a4b63ee16491f041e058c09d0", size = 211753, upload-time = "2025-09-25T05:59:00.561Z" }, - { url = "https://files.pythonhosted.org/packages/f2/2f/c10c739bcb9b072090c97c2e08533777497190daa19d190d72b4cce9c7cb/zope_interface-8.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4bd01022d2e1bce4a4a4ed9549edb25393c92e607d7daa6deff843f1f68b479d", size = 207903, upload-time = "2025-09-25T05:58:21.671Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e1/9845ac3697f108d9a1af6912170c59a23732090bbfb35955fe77e5544955/zope_interface-8.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:29be8db8b712d94f1c05e24ea230a879271d787205ba1c9a6100d1d81f06c69a", size = 208345, upload-time = "2025-09-25T05:58:24.217Z" }, - { url = "https://files.pythonhosted.org/packages/f2/49/6573bc8b841cfab18e80c8e8259f1abdbbf716140011370de30231be79ad/zope_interface-8.0.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:51ae1b856565b30455b7879fdf0a56a88763b401d3f814fa9f9542d7410dbd7e", size = 255027, upload-time = "2025-09-25T05:58:19.975Z" }, - { url = "https://files.pythonhosted.org/packages/e2/fd/908b0fd4b1ab6e412dfac9bd2b606f2893ef9ba3dd36d643f5e5b94c57b3/zope_interface-8.0.1-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d2e7596149cb1acd1d4d41b9f8fe2ffc0e9e29e2e91d026311814181d0d9efaf", size = 259800, upload-time = "2025-09-25T05:58:11.487Z" }, - { url = "https://files.pythonhosted.org/packages/dc/78/8419a2b4e88410520ed4b7f93bbd25a6d4ae66c4e2b131320f2b90f43077/zope_interface-8.0.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b2737c11c34fb9128816759864752d007ec4f987b571c934c30723ed881a7a4f", size = 260978, upload-time = "2025-09-25T06:26:24.483Z" }, - { url = "https://files.pythonhosted.org/packages/e5/90/caf68152c292f1810e2bd3acd2177badf08a740aa8a348714617d6c9ad0b/zope_interface-8.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:cf66e4bf731aa7e0ced855bb3670e8cda772f6515a475c6a107bad5cb6604103", size = 212155, upload-time = "2025-09-25T05:59:40.318Z" }, - { url = "https://files.pythonhosted.org/packages/dc/a6/0f08713ddda834c428ebf97b2a7fd8dea50c0100065a8955924dbd94dae8/zope_interface-8.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:115f27c1cc95ce7a517d960ef381beedb0a7ce9489645e80b9ab3cbf8a78799c", size = 208609, upload-time = "2025-09-25T05:58:53.698Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5e/d423045f54dc81e0991ec655041e7a0eccf6b2642535839dd364b35f4d7f/zope_interface-8.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af655c573b84e3cb6a4f6fd3fbe04e4dc91c63c6b6f99019b3713ef964e589bc", size = 208797, upload-time = "2025-09-25T05:58:56.258Z" }, - { url = "https://files.pythonhosted.org/packages/c6/43/39d4bb3f7a80ebd261446792493cfa4e198badd47107224f5b6fe1997ad9/zope_interface-8.0.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:23f82ef9b2d5370750cc1bf883c3b94c33d098ce08557922a3fbc7ff3b63dfe1", size = 259242, upload-time = "2025-09-25T05:58:21.602Z" }, - { url = "https://files.pythonhosted.org/packages/da/29/49effcff64ef30731e35520a152a9dfcafec86cf114b4c2aff942e8264ba/zope_interface-8.0.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35a1565d5244997f2e629c5c68715b3d9d9036e8df23c4068b08d9316dcb2822", size = 264696, upload-time = "2025-09-25T05:58:13.351Z" }, - { url = "https://files.pythonhosted.org/packages/c7/39/b947673ec9a258eeaa20208dd2f6127d9fbb3e5071272a674ebe02063a78/zope_interface-8.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:029ea1db7e855a475bf88d9910baab4e94d007a054810e9007ac037a91c67c6f", size = 264229, upload-time = "2025-09-25T06:26:26.226Z" }, - { url = "https://files.pythonhosted.org/packages/8f/ee/eed6efd1fc3788d1bef7a814e0592d8173b7fe601c699b935009df035fc2/zope_interface-8.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0beb3e7f7dc153944076fcaf717a935f68d39efa9fce96ec97bafcc0c2ea6cab", size = 212270, upload-time = "2025-09-25T05:58:53.584Z" }, - { url = "https://files.pythonhosted.org/packages/5f/dc/3c12fca01c910c793d636ffe9c0984e0646abaf804e44552070228ed0ede/zope_interface-8.0.1-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:c7cc027fc5c61c5d69e5080c30b66382f454f43dc379c463a38e78a9c6bab71a", size = 208992, upload-time = "2025-09-25T05:58:40.712Z" }, - { url = "https://files.pythonhosted.org/packages/46/71/6127b7282a3e380ca927ab2b40778a9c97935a4a57a2656dadc312db5f30/zope_interface-8.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fcf9097ff3003b7662299f1c25145e15260ec2a27f9a9e69461a585d79ca8552", size = 209051, upload-time = "2025-09-25T05:58:42.182Z" }, - { url = "https://files.pythonhosted.org/packages/56/86/4387a9f951ee18b0e41fda77da77d59c33e59f04660578e2bad688703e64/zope_interface-8.0.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6d965347dd1fb9e9a53aa852d4ded46b41ca670d517fd54e733a6b6a4d0561c2", size = 259223, upload-time = "2025-09-25T05:58:23.191Z" }, - { url = "https://files.pythonhosted.org/packages/61/08/ce60a114466abc067c68ed41e2550c655f551468ae17b4b17ea360090146/zope_interface-8.0.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9a3b8bb77a4b89427a87d1e9eb969ab05e38e6b4a338a9de10f6df23c33ec3c2", size = 264690, upload-time = "2025-09-25T05:58:15.052Z" }, - { url = "https://files.pythonhosted.org/packages/36/9a/62a9ba3a919594605a07c34eee3068659bbd648e2fa0c4a86d876810b674/zope_interface-8.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:87e6b089002c43231fb9afec89268391bcc7a3b66e76e269ffde19a8112fb8d5", size = 264201, upload-time = "2025-09-25T06:26:27.797Z" }, - { url = "https://files.pythonhosted.org/packages/da/06/8fe88bd7edef60566d21ef5caca1034e10f6b87441ea85de4bbf9ea74768/zope_interface-8.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:64a43f5280aa770cbafd0307cb3d1ff430e2a1001774e8ceb40787abe4bb6658", size = 212273, upload-time = "2025-09-25T06:00:25.398Z" }, - { url = "https://files.pythonhosted.org/packages/e5/24/d5c5e7936e014276b7a98a076de4f5dc2587100fea95779c1e36650b8770/zope_interface-8.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b84464a9fcf801289fa8b15bfc0829e7855d47fb4a8059555effc6f2d1d9a613", size = 207443, upload-time = "2025-09-25T05:59:34.299Z" }, - { url = "https://files.pythonhosted.org/packages/8a/76/565cf6db478ba344b27cfd6828f17da2888cf1beb521bce31142b3041fb5/zope_interface-8.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7b915cf7e747b5356d741be79a153aa9107e8923bc93bcd65fc873caf0fb5c50", size = 207928, upload-time = "2025-09-25T05:59:35.524Z" }, - { url = "https://files.pythonhosted.org/packages/65/03/9780355205c3b3f55e9ce700e52846b40d0bab99c078e102c0d7e2f3e022/zope_interface-8.0.1-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:110c73ddf974b369ef3c6e7b0d87d44673cf4914eba3fe8a33bfb21c6c606ad8", size = 248605, upload-time = "2025-09-25T05:58:25.586Z" }, - { url = "https://files.pythonhosted.org/packages/44/09/b10eda92f1373cd8e4e9dd376559414d1759a8b54e98eeef0d81844f0638/zope_interface-8.0.1-cp39-cp39-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9e9bdca901c1bcc34e438001718512c65b3b8924aabcd732b6e7a7f0cd715f17", size = 253793, upload-time = "2025-09-25T05:58:16.92Z" }, - { url = "https://files.pythonhosted.org/packages/62/37/3529065a2b6b7dc8f287ff3c8d1e8b7d8c4681c069e520bd3c4ac995a95c/zope_interface-8.0.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bbd22d4801ad3e8ec704ba9e3e6a4ac2e875e4d77e363051ccb76153d24c5519", size = 254263, upload-time = "2025-09-25T06:26:29.371Z" }, - { url = "https://files.pythonhosted.org/packages/c3/31/42588bea7ddad3abd2d4987ec3b767bd09394cc091a4918b1cf7b9de07ae/zope_interface-8.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:a0016ca85f93b938824e2f9a43534446e95134a2945b084944786e1ace2020bc", size = 211780, upload-time = "2025-09-25T05:59:50.015Z" }, -] - -[[package]] -name = "zope-interface" -version = "8.5" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] -sdist = { url = "https://files.pythonhosted.org/packages/08/dc/50550cfcbb2ea3cbca5f1d7ed05c8aa840f831a0f2d63aec0a953f7c590e/zope_interface-8.5.tar.gz", hash = "sha256:7a3ba1c5877f0f3e3906b02ddf793abed2becc2948116414ce0e1dd820b68d6d", size = 257957, upload-time = "2026-05-26T06:50:14.574Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/43/9cd98bee951d23848de690ba2809f87e3b22c67c370987acc960da15ad37/zope_interface-8.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c0c8aa2bf8f3911ef37b87deb1bbe225a310e6eb6522a16d77f5d8330c4f6fbe", size = 210951, upload-time = "2026-05-26T06:49:00.178Z" }, - { url = "https://files.pythonhosted.org/packages/17/0f/8f1a29966bcf863e3a2121edcafb81c55715de7886bcc9544749cc79e7da/zope_interface-8.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:efe234a0fafb4b6b1602e9be9245b97c2bf06d67c07af5a4bc3c0438978b555c", size = 211309, upload-time = "2026-05-26T06:49:02.732Z" }, - { url = "https://files.pythonhosted.org/packages/9f/9f/37e564eaaf85e3abc1ada40a79fa43f2ab45bdb67431b0ec0fe29e4763e2/zope_interface-8.5-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:dabeb6fe1228d411994f300811edc6866fff0cdcbc9cef98a78f05ea0da42e37", size = 254881, upload-time = "2026-05-26T06:49:04.303Z" }, - { url = "https://files.pythonhosted.org/packages/06/61/e6501d8ea7a2cac3217e03f404e1f98c1df7191d83cfe86b1895fbba5dac/zope_interface-8.5-cp310-cp310-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:147a9442dcc2b7339ecdb1be2b3cdb098e90462e39425054053ebfb50d99125a", size = 259811, upload-time = "2026-05-26T06:49:06.373Z" }, - { url = "https://files.pythonhosted.org/packages/91/15/bfa25ef480b02af6e9452c478483fec75e87c9e2b60c407fd0b1f6054b9c/zope_interface-8.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a17e681224267880707c9ec9e730ad9a1ad2d65c371256843efba6cf48711b58", size = 260358, upload-time = "2026-05-26T06:49:08.317Z" }, - { url = "https://files.pythonhosted.org/packages/64/51/2b518072fea76242da64451d501c69b7b5ccdef9b57fead584ccf1c180d5/zope_interface-8.5-cp310-cp310-win_amd64.whl", hash = "sha256:d178968a1a611df30549a717d1624cb38ca810347339e3e37b7baa6f6781a170", size = 214822, upload-time = "2026-05-26T06:49:10.441Z" }, - { url = "https://files.pythonhosted.org/packages/ce/f1/83ad110fb847413affe71609bb50e59e1aa082e1236030122227c7c283d3/zope_interface-8.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:afc66ccaef2a3c0bef6ca02aad40d29a39276389dad16a8eac36f9f385e4d057", size = 211426, upload-time = "2026-05-26T06:49:12.595Z" }, - { url = "https://files.pythonhosted.org/packages/bb/a7/6b6e0c31ac240cb9fc015ae9ed45ca54be886c18fcf7bfa2377a4d7a8785/zope_interface-8.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c28044972187245d7a309e4699319bfdbd2ffcbf7176d1d4ddf5adffb2dea80f", size = 211850, upload-time = "2026-05-26T06:49:14.474Z" }, - { url = "https://files.pythonhosted.org/packages/37/36/7599ecabcf80ce4fef2e1ef3c5ac0d4696b61f03f724cc44022f4d226af9/zope_interface-8.5-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:03bbecc7982af713d7499d4084bc03916413d17ffd45f89009348cc0c1d9e376", size = 260711, upload-time = "2026-05-26T06:49:16.568Z" }, - { url = "https://files.pythonhosted.org/packages/03/3e/1774b0ee46ccbb5498ee3c33ece40315b6ef58bc71957be94bd345340bc1/zope_interface-8.5-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf917009a4a7457c7290225a019f4a0aa706d96accd2cfdba2418d3bc1fcde2f", size = 265277, upload-time = "2026-05-26T06:49:18.656Z" }, - { url = "https://files.pythonhosted.org/packages/b6/09/e533b2ffabaae4e5d5730d6768a591cf335defe8e37bec2ad905d09be656/zope_interface-8.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:31cff25b2aaedb5267e6e77b1e9be6b0ec4f622032de8a069202b8ffacda7dc2", size = 266369, upload-time = "2026-05-26T06:49:20.174Z" }, - { url = "https://files.pythonhosted.org/packages/49/4a/3ebe6a4c122b2d5340db45cbe7e490663d3228b172710ec71060cd5d541e/zope_interface-8.5-cp311-cp311-win_amd64.whl", hash = "sha256:17a3114bbdddb5e75e5784cdf318944636190cbbc72d357ef9fb1a8b0351f955", size = 215161, upload-time = "2026-05-26T06:49:21.799Z" }, - { url = "https://files.pythonhosted.org/packages/d2/59/056ad97af5b16db1975ee98ec7ab03d2ce3f3355efad904ced1dbce0e39f/zope_interface-8.5-cp311-cp311-win_arm64.whl", hash = "sha256:aab6bb5bee10f38ea688b95ba054396b67f613552d2c8378be7fcb2d2fba7646", size = 213481, upload-time = "2026-05-26T06:49:25.085Z" }, - { url = "https://files.pythonhosted.org/packages/97/cc/b84123a948f3162a34623e188922827cd845244fdd043ed20f8d02228caa/zope_interface-8.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:8e6ee90c2e6de7c37058d5fa41f123c8b13a312db8d1e0fb5840d7f4bcdff9c9", size = 212165, upload-time = "2026-05-26T06:49:26.566Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/cbceec44f1b27208a76c1a688c131302685852406a23df5aab68324109cc/zope_interface-8.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c1adc90d3576b3b4c4de4953e6002c37bef28b78d7fa54c1bbfd0c50f022fe7c", size = 212341, upload-time = "2026-05-26T06:49:28.182Z" }, - { url = "https://files.pythonhosted.org/packages/e1/c3/005032195ff3b210c139b7c560ed5c534e844b0907d8e44d2b3d8919305e/zope_interface-8.5-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:e6347b8d8d12c5eca6502450a92be30079b7acfade2c4f693efa0deb8871b06e", size = 265296, upload-time = "2026-05-26T06:49:29.741Z" }, - { url = "https://files.pythonhosted.org/packages/c5/66/1036543d6a66bc04c19df3cf650f3ad938a002ab0a443c24e23e8de5e8b9/zope_interface-8.5-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5e970dabea777a24b0b0bbf9dae3ab75ce8b2d8e948edf4875627034b21f3560", size = 270689, upload-time = "2026-05-26T06:49:31.767Z" }, - { url = "https://files.pythonhosted.org/packages/30/4c/8b56259558cace4414e753ca6740396a1f59d4a95ddb55b4658600408670/zope_interface-8.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f0b48ccadaa9839e09ff81e969703cecb3f402c813bfe8b958652e699bea69f5", size = 270280, upload-time = "2026-05-26T06:49:33.489Z" }, - { url = "https://files.pythonhosted.org/packages/f9/ea/649908c83aa8fdb7faf2ddca4d3cf6fb8f2157121267dc56e8f72681e26c/zope_interface-8.5-cp312-cp312-win_amd64.whl", hash = "sha256:e0e311f1277468c08fd59a2b41f71b43d25dff639789d364747acd1705c0df6e", size = 215019, upload-time = "2026-05-26T06:49:35.607Z" }, - { url = "https://files.pythonhosted.org/packages/9f/97/da13037b4c563e4df32eedbc819f8c00b754af494f68211e3dffd48d52da/zope_interface-8.5-cp312-cp312-win_arm64.whl", hash = "sha256:652b73107a04159ec6c020db6c1543d4f1e8f4d069bd2aac88a947820923517b", size = 213569, upload-time = "2026-05-26T06:49:37.317Z" }, - { url = "https://files.pythonhosted.org/packages/f4/8c/4c15755d701f2ec0e80d64a18e1ebaf5be2c584c0ec153fd516f5d13eada/zope_interface-8.5-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:28e80457c134d1fa57a7d758004dece348654e1b1467ac22dcdc20fc1d127c52", size = 212512, upload-time = "2026-05-26T06:49:38.996Z" }, - { url = "https://files.pythonhosted.org/packages/9a/2e/4360c54c465db042cc8fbeeec92abac28b4cedbf6ba63c1f092fd08a190f/zope_interface-8.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:09495ce9d559c06b70f2d4855b3e4f48a822a9ddc8be1d30c5b4e5be14ae1ace", size = 212541, upload-time = "2026-05-26T06:49:41.186Z" }, - { url = "https://files.pythonhosted.org/packages/aa/a5/692a2b8d70f78e848793231d5fae5fecbf8d0cccd73430fdc34802a6d3c1/zope_interface-8.5-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:7849ad8fa90763cc1087f4dda78ca3a233e950b3e08fac7079297c9cafbbd7bb", size = 265191, upload-time = "2026-05-26T06:49:43.449Z" }, - { url = "https://files.pythonhosted.org/packages/70/8d/454a9cfc7a050c394ab4f11b3371f7897828b7415e096afff724637e65e0/zope_interface-8.5-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5578c9421ca409a1f39f153d6f7803e4cde01da592ec75a9ac5e1b777d18d33b", size = 270626, upload-time = "2026-05-26T06:49:45.425Z" }, - { url = "https://files.pythonhosted.org/packages/51/8c/db8409cfa3575b8e9b4800babd7d49f8228433cd1f0c56814bd0ada49c33/zope_interface-8.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e1bd7d96b4ca5fa311f54c9eac16dce4886b428c1531dbe06067763ccdf123b4", size = 270444, upload-time = "2026-05-26T06:49:47.025Z" }, - { url = "https://files.pythonhosted.org/packages/4a/df/a386940e41469ef615e100a216d8b386521e9e598817147f87932ca203c4/zope_interface-8.5-cp313-cp313-win_amd64.whl", hash = "sha256:0c8123d2a4dfde2a613c7cb772605477724782c20bc2e0ad1d9435376a6a44a3", size = 215021, upload-time = "2026-05-26T06:49:48.478Z" }, - { url = "https://files.pythonhosted.org/packages/89/75/477eb5669b6b2a7a843decd1a075e9b1971a8720017654143a7183abd3d9/zope_interface-8.5-cp313-cp313-win_arm64.whl", hash = "sha256:6d02be14f3173c6c7288bc2fdf530090c01c3cf8764ad46c68024686f364278e", size = 213610, upload-time = "2026-05-26T06:49:50.01Z" }, - { url = "https://files.pythonhosted.org/packages/d4/19/5032e954827fdf02db2d2f49737ac4378bb9cfc2cd95a8f2e2a5ae2ec01a/zope_interface-8.5-cp314-cp314-macosx_10_9_x86_64.whl", hash = "sha256:ffaecf013251a89d0de6feb49a46eba48ad8cbbf8a40aeb6045e459e7bec6784", size = 212597, upload-time = "2026-05-26T06:49:51.63Z" }, - { url = "https://files.pythonhosted.org/packages/f1/53/3ef644012cf8a6a234a2d6134aab5a5c65ac5467c86296865501d4fbc406/zope_interface-8.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:126fa9d1c52295ae076d4cf968634f0a1826afa408a20808b57ff72877b8f69f", size = 212626, upload-time = "2026-05-26T06:49:53.236Z" }, - { url = "https://files.pythonhosted.org/packages/32/67/bc8b4f465d388039255003e230c284a175cedf1203c692f23cb7bff64efe/zope_interface-8.5-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:3090e3a663d20194756a59a272e0c8508b889341e31d5894223331fe6b4f9b21", size = 266827, upload-time = "2026-05-26T06:49:54.873Z" }, - { url = "https://files.pythonhosted.org/packages/a7/eb/37d05b935ede53d79690fecc8d201440084418e590bcfc05f384451c7593/zope_interface-8.5-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9342fb74e2afefdb081bf1df727d209ea56995c6e13f5a0540e6d7aff4beafb8", size = 270139, upload-time = "2026-05-26T06:49:57.116Z" }, - { url = "https://files.pythonhosted.org/packages/8b/0b/fd0c54579e2ce8dc6cf1a757903f3374bc6fbda929a46af9e0f53cb0e5f0/zope_interface-8.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6c54725d818f1b57a7efb8b16528326e1f3c257b602b32393fd255c45af8799d", size = 270338, upload-time = "2026-05-26T06:49:58.698Z" }, - { url = "https://files.pythonhosted.org/packages/c1/1d/c420dcd777bb761067ea92879ac766694a5ca78608185f1aecea64cbfc11/zope_interface-8.5-cp314-cp314-win_amd64.whl", hash = "sha256:29d74febbae1afeb6834c4ccbf42e242a673c860060f09e53142825270456140", size = 215789, upload-time = "2026-05-26T06:50:00.405Z" }, - { url = "https://files.pythonhosted.org/packages/62/94/50b5eb8f94e527edceac14f9955e58917424ea79bb572ddc18548561cbc2/zope_interface-8.5-cp314-cp314-win_arm64.whl", hash = "sha256:633c8c49396f38df030340797c533e9fe460d1b5d1e42d88e55e938e525f548c", size = 213757, upload-time = "2026-05-26T06:50:01.973Z" }, - { url = "https://files.pythonhosted.org/packages/17/6f/5d5f32c4dfcdb16ce2ec5363da686840f13c13e1a1214cb70b49e1cd6d9f/zope_interface-8.5-cp314-cp314t-macosx_10_9_x86_64.whl", hash = "sha256:133999820fdbae513c36c03d6f29ef87317aaa3edef39112222b155083664714", size = 213591, upload-time = "2026-05-26T06:50:03.529Z" }, - { url = "https://files.pythonhosted.org/packages/f3/55/de0c3459ff717fce3342f9a29464c281fdeb0d36c3171ee88d119d5f0650/zope_interface-8.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8bd75c96966e573232f0599deaff717564828031c7f05563ccc1ac35c5ee0304", size = 213733, upload-time = "2026-05-26T06:50:05.101Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/d97430abd5ae9677e8b9295b58720c0064a5b557dbb6b8bf5928484cf0d8/zope_interface-8.5-cp314-cp314t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:14b0e9799351d4c34fe99afd67f0cdd76e55ba15c66a98699d5fc22ea8241e08", size = 294905, upload-time = "2026-05-26T06:50:07.384Z" }, - { url = "https://files.pythonhosted.org/packages/41/ec/a0f8f3dad6e74992f4654bdd94802be0929eabca7b871cac3b6fbb5e961b/zope_interface-8.5-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0cd6a732ac84b94eb1ef9222a117347a27efd294ee16810ffdf7ecd307677ed5", size = 300885, upload-time = "2026-05-26T06:50:08.997Z" }, - { url = "https://files.pythonhosted.org/packages/0f/da/6881b48803a0ee8d23eb5efa30fce3ed218a2bd9de5758ce489d224fee81/zope_interface-8.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:798b7c87d0e59a7d5d086d642208d0d8700ff0d55c4029134b3c479c3bfb110f", size = 304672, upload-time = "2026-05-26T06:50:10.563Z" }, - { url = "https://files.pythonhosted.org/packages/2e/0e/b4c01320859ff1d585438bc231fd60bd258d096359bccf6654fecdf0cffb/zope_interface-8.5-cp314-cp314t-win_amd64.whl", hash = "sha256:0fc3a9d45f114d27eaa1e53beeb144533689edca8a9f66505b1e8e8b3f075e42", size = 217241, upload-time = "2026-05-26T06:50:12.171Z" }, -] From 8c0d85c4df27e9a124c0967d45cae6ec71f852d0 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Tue, 18 Aug 2026 11:59:55 +0200 Subject: [PATCH 144/200] tests: shut down the Clusters created in test_cluster.py Every Cluster starts a _Scheduler thread in __init__, so a test that constructs one and drops it leaves that thread alive for the rest of the session. Running the module one test at a time showed 37 of its tests leaking 45 threads between them; a full run of the unit suite carried them all to the end. Register the shutdown with addCleanup rather than wrapping each test in try/finally: it runs even when an assertion fails, and it does not reindent the assertions it protects. Three tests had no name to shut down, since they asserted directly on a freshly constructed Cluster, so those constructions are now bound first. Constructions that raise are left alone: the argument validation they exercise runs before the scheduler is started, so nothing is leaked and there is no object to close. Co-Authored-By: Claude Opus 5 (1M context) --- tests/unit/test_cluster.py | 52 ++++++++++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/tests/unit/test_cluster.py b/tests/unit/test_cluster.py index 9a41cf1552..f3533352a4 100644 --- a/tests/unit/test_cluster.py +++ b/tests/unit/test_cluster.py @@ -152,6 +152,7 @@ class ClusterTest(unittest.TestCase): def test_tuple_for_contact_points(self): cluster = Cluster(contact_points=[('localhost', 9045), ('127.0.0.2', 9046), '127.0.0.3'], port=9999) + self.addCleanup(cluster.shutdown) # Refactored for clarity addr_info = socket.getaddrinfo("localhost", 80) sockaddr_tuples = [info[4] for info in addr_info] # info[4] is sockaddr @@ -174,6 +175,7 @@ def test_invalid_contact_point_types(self): def test_port_str(self): """Check port passed as string is converted and checked properly""" cluster = Cluster(contact_points=['127.0.0.1'], port='1111') + self.addCleanup(cluster.shutdown) for cp in cluster.endpoints_resolved: if cp.address in ('::1', '127.0.0.1'): assert cp.port == 1111 @@ -188,24 +190,29 @@ def test_port_range(self): cluster = Cluster(contact_points=['127.0.0.1'], port=invalid_port) def test_control_connection_query_fallback_modes(self): - assert Cluster().allow_control_connection_query_fallback is ControlConnectionQueryFallback.Disabled + default_cluster = Cluster() + self.addCleanup(default_cluster.shutdown) + assert default_cluster.allow_control_connection_query_fallback is ControlConnectionQueryFallback.Disabled with pytest.raises(TypeError): Cluster(allow_control_connection_query_fallback=False) with pytest.raises(TypeError): Cluster(allow_control_connection_query_fallback=True) - assert ( - Cluster(allow_control_connection_query_fallback=ControlConnectionQueryFallback.Fallback) - .allow_control_connection_query_fallback + fallback_cluster = Cluster( + allow_control_connection_query_fallback=ControlConnectionQueryFallback.Fallback) + self.addCleanup(fallback_cluster.shutdown) + assert fallback_cluster.allow_control_connection_query_fallback \ is ControlConnectionQueryFallback.Fallback - ) - assert Cluster( - allow_control_connection_query_fallback=ControlConnectionQueryFallback.SkipPoolCreation - ).allow_control_connection_query_fallback is ControlConnectionQueryFallback.SkipPoolCreation + skip_pool_cluster = Cluster( + allow_control_connection_query_fallback=ControlConnectionQueryFallback.SkipPoolCreation) + self.addCleanup(skip_pool_cluster.shutdown) + assert skip_pool_cluster.allow_control_connection_query_fallback \ + is ControlConnectionQueryFallback.SkipPoolCreation def test_control_connection_query_fallback_no_node_pool_mode_skips_pool_creation(self): cluster = Cluster( allow_control_connection_query_fallback=ControlConnectionQueryFallback.SkipPoolCreation, ) + self.addCleanup(cluster.shutdown) host = Host("127.0.0.1", SimpleConvictionPolicy, host_id=uuid.uuid4()) with patch.object(Session, "add_or_renew_pool") as mocked_add_or_renew_pool: @@ -220,6 +227,7 @@ def test_control_connection_query_fallback_fallback_tolerates_empty_initial_pool cluster = Cluster( allow_control_connection_query_fallback=ControlConnectionQueryFallback.Fallback, ) + self.addCleanup(cluster.shutdown) host = Host("127.0.0.1", SimpleConvictionPolicy, host_id=uuid.uuid4()) future = Future() future.set_result(False) @@ -235,6 +243,7 @@ def test_compression_autodisabled_without_libraries(self): with patch.dict('cassandra.cluster.locally_supported_compressions', {}, clear=True): with patch('cassandra.cluster.log') as patched_logger: cluster = Cluster(compression=True) + self.addCleanup(cluster.shutdown) patched_logger.error.assert_called_once() assert cluster.compression is False @@ -247,6 +256,7 @@ def test_compression_validates_requested_algorithm(self): with patch.dict('cassandra.cluster.locally_supported_compressions', {'lz4': ('c', 'd')}, clear=True): with patch('cassandra.cluster.log') as patched_logger: cluster = Cluster(compression='lz4') + self.addCleanup(cluster.shutdown) patched_logger.error.assert_not_called() assert cluster.compression == 'lz4' @@ -269,6 +279,7 @@ def test_connection_factory_passes_compression_kwarg(self): with patch.dict('cassandra.cluster.locally_supported_compressions', supported, clear=True): with patch.object(Cluster.connection_class, 'factory', autospec=True, return_value='connection') as factory: cluster = Cluster(compression=configured) + self.addCleanup(cluster.shutdown) conn = cluster.connection_factory(endpoint) assert conn == 'connection' @@ -388,6 +399,7 @@ def _new_schema_agreement_session(self, schema_versions, distances=None): distance_map[host] = distances[index] cluster = Cluster(protocol_version=4) + self.addCleanup(cluster.shutdown) for host in hosts: cluster.metadata.add_or_return_host(host) @@ -421,6 +433,7 @@ def test_default_serial_consistency_level_ep(self, *_): PR #510 """ c = Cluster(protocol_version=4) + self.addCleanup(c.shutdown) s = Session(c, [Host("127.0.0.1", SimpleConvictionPolicy, host_id=uuid.uuid4())]) c.connection_class.initialize_reactor() @@ -450,6 +463,7 @@ def test_default_serial_consistency_level_legacy(self, *_): PR #510 """ c = Cluster(protocol_version=4) + self.addCleanup(c.shutdown) s = Session(c, [Host("127.0.0.1", SimpleConvictionPolicy, host_id=uuid.uuid4())]) c.connection_class.initialize_reactor() # default is None @@ -480,6 +494,7 @@ def test_set_keyspace_escapes_quotes(self, *_): Requested in review of PR #758. """ c = Cluster(protocol_version=4) + self.addCleanup(c.shutdown) s = Session(c, [Host("127.0.0.1", SimpleConvictionPolicy, host_id=uuid.uuid4())]) c.connection_class.initialize_reactor() @@ -599,6 +614,7 @@ def test_wait_for_schema_agreement_rejects_unknown_scope(self, *_): @mock_session_pools def test_set_keyspace_for_all_pools_reports_all_errors(self, *_): cluster = Cluster() + self.addCleanup(cluster.shutdown) session = Session( cluster, [Host("127.0.0.1", SimpleConvictionPolicy, host_id=uuid.uuid4())], @@ -653,6 +669,7 @@ def _verify_response_future_profile(self, rf, prof): @mock_session_pools def test_default_exec_parameters(self): cluster = Cluster() + self.addCleanup(cluster.shutdown) assert cluster._config_mode == _ConfigMode.UNCOMMITTED assert cluster.load_balancing_policy.__class__ == default_lbp_factory().__class__ assert cluster.profile_manager.default.load_balancing_policy.__class__ == default_lbp_factory().__class__ @@ -671,6 +688,7 @@ def test_default_exec_parameters(self): @mock_session_pools def test_default_legacy(self): cluster = Cluster(load_balancing_policy=RoundRobinPolicy(), default_retry_policy=DowngradingConsistencyRetryPolicy()) + self.addCleanup(cluster.shutdown) assert cluster._config_mode == _ConfigMode.LEGACY session = Session(cluster, hosts=[Host("127.0.0.1", SimpleConvictionPolicy, host_id=uuid.uuid4())]) session.default_timeout = 3.7 @@ -686,6 +704,7 @@ def test_default_legacy(self): def test_default_profile(self): non_default_profile = ExecutionProfile(RoundRobinPolicy(), *[object() for _ in range(2)]) cluster = Cluster(execution_profiles={'non-default': non_default_profile}) + self.addCleanup(cluster.shutdown) session = Session(cluster, hosts=[Host("127.0.0.1", SimpleConvictionPolicy, host_id=uuid.uuid4())]) assert cluster._config_mode == _ConfigMode.PROFILES @@ -718,6 +737,7 @@ def test_serial_consistency_level_validation(self): @mock_session_pools def test_statement_params_override_legacy(self): cluster = Cluster(load_balancing_policy=RoundRobinPolicy(), default_retry_policy=DowngradingConsistencyRetryPolicy()) + self.addCleanup(cluster.shutdown) assert cluster._config_mode == _ConfigMode.LEGACY session = Session(cluster, hosts=[Host("127.0.0.1", SimpleConvictionPolicy, host_id=uuid.uuid4())]) @@ -740,6 +760,7 @@ def test_statement_params_override_legacy(self): def test_statement_params_override_profile(self): non_default_profile = ExecutionProfile(RoundRobinPolicy(), *[object() for _ in range(2)]) cluster = Cluster(execution_profiles={'non-default': non_default_profile}) + self.addCleanup(cluster.shutdown) session = Session(cluster, hosts=[Host("127.0.0.1", SimpleConvictionPolicy, host_id=uuid.uuid4())]) assert cluster._config_mode == _ConfigMode.PROFILES @@ -773,11 +794,13 @@ def test_no_profile_with_legacy(self): # can't add after cluster = Cluster(load_balancing_policy=RoundRobinPolicy()) + self.addCleanup(cluster.shutdown) with pytest.raises(ValueError): cluster.add_execution_profile('name', ExecutionProfile()) # session settings lock out profiles cluster = Cluster() + self.addCleanup(cluster.shutdown) session = Session(cluster, hosts=[Host("127.0.0.1", SimpleConvictionPolicy, host_id=uuid.uuid4())]) for attr, value in (('default_timeout', 1), ('default_consistency_level', ConsistencyLevel.ANY), @@ -796,6 +819,8 @@ def test_no_profile_with_legacy(self): def test_no_legacy_with_profile(self): cluster_init = Cluster(execution_profiles={'name': ExecutionProfile()}) cluster_add = Cluster() + self.addCleanup(cluster_init.shutdown) + self.addCleanup(cluster_add.shutdown) cluster_add.add_execution_profile('name', ExecutionProfile()) # for clusters with profiles added either way... for cluster in (cluster_init, cluster_init): @@ -817,6 +842,7 @@ def test_profile_name_value(self): internalized_profile = ExecutionProfile(RoundRobinPolicy(), *[object() for _ in range(2)]) cluster = Cluster(execution_profiles={'by-name': internalized_profile}) + self.addCleanup(cluster.shutdown) session = Session(cluster, hosts=[Host("127.0.0.1", SimpleConvictionPolicy, host_id=uuid.uuid4())]) assert cluster._config_mode == _ConfigMode.PROFILES @@ -831,6 +857,7 @@ def test_profile_name_value(self): def test_exec_profile_clone(self): cluster = Cluster(execution_profiles={EXEC_PROFILE_DEFAULT: ExecutionProfile(), 'one': ExecutionProfile()}) + self.addCleanup(cluster.shutdown) session = Session(cluster, hosts=[Host("127.0.0.1", SimpleConvictionPolicy, host_id=uuid.uuid4())]) profile_attrs = {'request_timeout': 1, @@ -862,6 +889,7 @@ def test_exec_profile_clone(self): def test_no_profiles_same_name(self): # can override default in init cluster = Cluster(execution_profiles={EXEC_PROFILE_DEFAULT: ExecutionProfile(), 'one': ExecutionProfile()}) + self.addCleanup(cluster.shutdown) # cannot update default with pytest.raises(ValueError): @@ -913,7 +941,8 @@ def test_warning_on_no_lbp_with_contact_points_profile_mode(self): @mock_session_pools def _check_warning_on_no_lbp_with_contact_points(self, cluster_kwargs): with patch('cassandra.cluster.log') as patched_logger: - Cluster(**cluster_kwargs) + cluster = Cluster(**cluster_kwargs) + self.addCleanup(cluster.shutdown) patched_logger.warning.assert_called_once() warning_message = patched_logger.warning.call_args[0][0] assert 'please specify a load-balancing policy' in warning_message @@ -968,7 +997,8 @@ def _check_no_warning_on_contact_points_with_lbp(self, cluster_kwargs): @test_category configuration """ with patch('cassandra.cluster.log') as patched_logger: - Cluster(**cluster_kwargs) + cluster = Cluster(**cluster_kwargs) + self.addCleanup(cluster.shutdown) patched_logger.warning.assert_not_called() @mock_session_pools @@ -977,6 +1007,7 @@ def test_warning_adding_no_lbp_ep_to_cluster_with_contact_points(self): cluster = Cluster( contact_points=['127.0.0.1'], execution_profiles={EXEC_PROFILE_DEFAULT: ep_with_lbp}) + self.addCleanup(cluster.shutdown) with patch('cassandra.cluster.log') as patched_logger: cluster.add_execution_profile( name='no_lbp', @@ -995,6 +1026,7 @@ def test_no_warning_adding_lbp_ep_to_cluster_with_contact_points(self): cluster = Cluster( contact_points=['127.0.0.1'], execution_profiles={EXEC_PROFILE_DEFAULT: ep_with_lbp}) + self.addCleanup(cluster.shutdown) with patch('cassandra.cluster.log') as patched_logger: cluster.add_execution_profile( name='with_lbp', From 7b10e4a535f82194674410981b5c243a9c9d7c95 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Thu, 20 Aug 2026 10:46:22 +0200 Subject: [PATCH 145/200] Fix _send_startup_message with no extra_options The parameter defaults to None but is splatted into the options dict, so omitting it raises a TypeError. defunct_on_error catches that and defuncts the connection, making the failure silent: no STARTUP frame is sent and nothing reports why. Unreachable from the driver since f36ba79fe added the parameter, because the one caller there always passes a dict. The caller that does omit it is the mock the simulacron heartbeat test installs over the options exchange, which has therefore been defuncting every connection it touched instead of skipping straight to STARTUP. --- cassandra/connection.py | 2 +- tests/unit/test_connection.py | 27 ++++++++++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/cassandra/connection.py b/cassandra/connection.py index 9f62deda7c..24b07f89e6 100644 --- a/cassandra/connection.py +++ b/cassandra/connection.py @@ -1565,7 +1565,7 @@ def _send_startup_message(self, compression=None, no_compact=False, extra_option log.debug("Sending StartupMessage on %s", self) opts = {'DRIVER_NAME': DRIVER_NAME, 'DRIVER_VERSION': DRIVER_VERSION, - **extra_options} + **(extra_options or {})} if compression: opts['COMPRESSION'] = compression if no_compact: diff --git a/tests/unit/test_connection.py b/tests/unit/test_connection.py index 8fdedd723f..ee9dac4aa1 100644 --- a/tests/unit/test_connection.py +++ b/tests/unit/test_connection.py @@ -22,7 +22,8 @@ from cassandra.cluster import Cluster from cassandra.connection import (Connection, HEADER_DIRECTION_TO_CLIENT, ProtocolError, locally_supported_compressions, ConnectionHeartbeat, HeartbeatFuture, _Frame, Timer, TimerManager, - ConnectionException, ConnectionShutdown, DefaultEndPoint, ShardAwarePortGenerator) + ConnectionException, ConnectionShutdown, DefaultEndPoint, ShardAwarePortGenerator, + DRIVER_NAME) from cassandra.marshal import uint8_pack, uint32_pack, int32_pack from cassandra.protocol import (write_stringmultimap, write_int, write_string, SupportedMessage, ProtocolHandler, ResultMessage, @@ -238,6 +239,30 @@ def test_disable_compression(self, *args): assert c.decompressor == None + def test_startup_message_can_be_sent_without_extra_options(self): + """ + _send_startup_message defaults extra_options to None but splats it, so + omitting it raised a TypeError that defunct_on_error turned into a silent + defunct: no STARTUP frame was ever sent. + + The only caller inside the driver always passes a dict, so the default + went unexercised; the caller that does omit it is the mock the simulacron + heartbeat test installs over the options exchange, which has therefore + been defuncting every connection it touched. + """ + c = self.make_connection() + c.send_msg = Mock() + c.defunct = Mock() + c.cql_version = '3.0.3' + + c._send_startup_message(no_compact=True) + + c.defunct.assert_not_called() + c.send_msg.assert_called_once() + options = c.send_msg.call_args[0][0].options + assert options['DRIVER_NAME'] == DRIVER_NAME + assert options['NO_COMPACT'] == 'true' + def test_not_implemented(self): """ Ensure the following methods throw NIE's. If not, come back and test them. From 845ee5f0b22e14017903de902d4ce6a3e8bbe68c Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Thu, 20 Aug 2026 10:46:37 +0200 Subject: [PATCH 146/200] DRIVER-950: Add a driver configuration reporter ScyllaDB echoes the CQL STARTUP options into system.clients.client_options, so an operator investigating an incident can inspect a client's settings without access to its host. Add the reporter that builds DRIVER_CONFIG, a JSON description of the effective configuration, and the name of the SESSION_ID option it is reported beside. This stage reports only the schema version. Building it is best effort: a report that cannot be built, or that exceeds the 32 KiB cap, is logged and left out, so a diagnostic aid can never fail a handshake. The cap matters because STARTUP values carry a 16 bit length prefix and later configuration groups describe user supplied values. --- cassandra/driver_config.py | 132 +++++++++++++++++++++++++++++++ tests/unit/test_driver_config.py | 113 ++++++++++++++++++++++++++ tests/unit/utils.py | 14 ++++ 3 files changed, 259 insertions(+) create mode 100644 cassandra/driver_config.py create mode 100644 tests/unit/test_driver_config.py diff --git a/cassandra/driver_config.py b/cassandra/driver_config.py new file mode 100644 index 0000000000..af5bf276f2 --- /dev/null +++ b/cassandra/driver_config.py @@ -0,0 +1,132 @@ +# Copyright 2026 ScyllaDB, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Reporting of the driver's own identity and configuration to the cluster through +the CQL ``STARTUP`` options. ScyllaDB echoes those options into the +``client_options`` column of its clients table, so an operator investigating an +incident can inspect the settings of a client without access to its host. +""" + +import json +import logging + +log = logging.getLogger(__name__) + + +SESSION_ID_OPTION = 'SESSION_ID' +""" +``STARTUP`` option correlating the connections that belong to the same +:class:`~.Cluster` in the clients table. Every connection reports it, since +correlating them is the whole point of it. + +The name follows the convention shared with the other ScyllaDB drivers, where a +"session" is what this driver calls a :class:`~.Cluster`; it is unrelated to +:attr:`.Session.session_id`. +""" + +DRIVER_CONFIG_OPTION = 'DRIVER_CONFIG' +""" +``STARTUP`` option holding the JSON description of the effective driver +configuration. The configuration is the same for every connection of a cluster, +so only the control connection reports it, keeping the other ``STARTUP`` frames +small. +""" + +DRIVER_CONFIG_SCHEMA_VERSION = 1 +""" +Major version of the reported configuration schema. Adding keys to the report is +backwards compatible and does not bump it, only changing or removing the meaning +of an existing key does. +""" + +MAX_DRIVER_CONFIG_LENGTH = 32 * 1024 +""" +Upper bound for the length, in bytes, of the :const:`DRIVER_CONFIG_OPTION` value. + +``STARTUP`` options are serialized by :func:`cassandra.protocol.write_string`, +which prefixes every value with a 16 bit length, so a longer value would fail to +pack and take the handshake down with it. The report is a handful of bytes for +now, but the configuration groups added later describe user supplied values, +such as the settings of custom policies, and can grow arbitrarily large. +Enforcing a limit here keeps "reporting must never prevent a connection from +being established" a property of this module rather than of the user's +configuration. + +32 KiB rather than the protocol's own 65535 byte ceiling: real world reports are +expected to stay well under a couple of kilobytes, so this leaves ample headroom +while remaining far short of the point where the value would stop protecting +anything. +""" + + +class DriverConfigReporter: + """ + Builds the :const:`DRIVER_CONFIG_OPTION` ``STARTUP`` option describing the + effective configuration of a :class:`~.Cluster`. + + One instance is created per :class:`~.Cluster` and shared by all of its + connections, but only the control connection ever asks it for options. Which + connections report is decided by + :meth:`cassandra.connection.Connection._handle_options_response`, not here. + """ + + def add_startup_options(self, options): + """ + Adds the configuration report to the ``STARTUP`` options being built. + + Reporting is best effort: this runs while a connection is being + established, so a report that cannot be built or does not fit is logged + and left out rather than allowed to fail the connection. + + Everything up to and including the assignment is guarded, not just the + building of the report: :meth:`_populate_report` is an extension point, + so a subclass returning something that is not a string has to be as + harmless as one raising. The assignment comes last, so nothing partial is + left in ``options`` either. + """ + try: + report = self._build_report() + length = len(report.encode('utf8')) + if length > MAX_DRIVER_CONFIG_LENGTH: + log.warning("The driver configuration report is %d bytes long, which exceeds the " + "%d bytes limit, it will not be reported to the cluster", + length, MAX_DRIVER_CONFIG_LENGTH) + return + + options[DRIVER_CONFIG_OPTION] = report + except Exception: + log.warning("Unable to build the driver configuration report, " + "it will not be reported to the cluster", exc_info=True) + + def _build_report(self): + """ + Returns the JSON configuration report. + + It is built for every control connection rather than cached, so that it + always describes the configuration as it is at that point in time. Later + configuration groups may well describe state that is only known once the + cluster has been contacted. + """ + report = {'version': DRIVER_CONFIG_SCHEMA_VERSION} + self._populate_report(report) + # Separators without whitespace: the report is a wire value bounded by + # MAX_DRIVER_CONFIG_LENGTH, not something meant to be read as it is. + return json.dumps(report, separators=(',', ':')) + + def _populate_report(self, report): + """ + Extension point for adding the configuration groups themselves to the + report. Empty for now. + """ + pass diff --git a/tests/unit/test_driver_config.py b/tests/unit/test_driver_config.py new file mode 100644 index 0000000000..e9d94c92fc --- /dev/null +++ b/tests/unit/test_driver_config.py @@ -0,0 +1,113 @@ +# Copyright 2026 ScyllaDB, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import unittest + +from cassandra.driver_config import (DriverConfigReporter, DRIVER_CONFIG_OPTION, + DRIVER_CONFIG_SCHEMA_VERSION, MAX_DRIVER_CONFIG_LENGTH) +from tests.unit.utils import ThrowingReporter + + +class OversizedReporter(DriverConfigReporter): + """ + Produces a report one byte past the limit. The schema-only report built by + :class:`.DriverConfigReporter` cannot reach the limit on its own, so the + guard is only reachable through a subclass. + """ + def _build_report(self): + return 'a' * (MAX_DRIVER_CONFIG_LENGTH + 1) + + +class MistypedReporter(DriverConfigReporter): + """ + Returns something that is not a string, the mistake the ``_populate_report`` + extension point invites once it describes more than the schema version. + """ + def _build_report(self): + return None + + +class DriverConfigReporterTest(unittest.TestCase): + def test_reports_the_schema_version(self): + options = {} + + DriverConfigReporter().add_startup_options(options) + + assert json.loads(options[DRIVER_CONFIG_OPTION]) == {'version': DRIVER_CONFIG_SCHEMA_VERSION} + + def test_report_is_compact_json(self): + """ + The report is a wire value bounded by MAX_DRIVER_CONFIG_LENGTH, not + something meant to be read as it is, so it carries no padding. + """ + options = {} + + DriverConfigReporter().add_startup_options(options) + + assert options[DRIVER_CONFIG_OPTION] == '{"version":%d}' % DRIVER_CONFIG_SCHEMA_VERSION + + def test_report_fits_within_the_length_limit(self): + """ + Tripwire for when the actual configuration groups land: a report over the + limit is dropped by add_startup_options, so this would fail with a clear + message instead of the size assertion raising an unrelated KeyError. + """ + options = {} + + DriverConfigReporter().add_startup_options(options) + + assert DRIVER_CONFIG_OPTION in options, \ + "the report was dropped, it must have exceeded the length limit" + # The limit is enforced on the encoded length, so measure bytes as well. + assert len(options[DRIVER_CONFIG_OPTION].encode('utf8')) <= MAX_DRIVER_CONFIG_LENGTH + + def test_oversized_report_is_not_reported(self): + options = {} + + OversizedReporter().add_startup_options(options) + + assert DRIVER_CONFIG_OPTION not in options + + def test_failure_to_build_the_report_is_not_reported(self): + """ + Building the report must never take a connection down with it: the + exception is swallowed and the option left out. + """ + options = {} + + ThrowingReporter().add_startup_options(options) + + assert DRIVER_CONFIG_OPTION not in options + + def test_a_report_that_is_not_a_string_is_not_reported(self): + """ + The guard covers the whole method, not just building the report: a + subclass returning a non-string must be as harmless as one raising, since + an exception here would reach the connection and defunct it. + """ + options = {} + + MistypedReporter().add_startup_options(options) + + assert DRIVER_CONFIG_OPTION not in options + + def test_other_options_are_left_alone(self): + options = {'APPLICATION_NAME': 'app'} + + OversizedReporter().add_startup_options(options) + MistypedReporter().add_startup_options(options) + DriverConfigReporter().add_startup_options(options) + + assert options['APPLICATION_NAME'] == 'app' diff --git a/tests/unit/utils.py b/tests/unit/utils.py index ec9a674799..d843358225 100644 --- a/tests/unit/utils.py +++ b/tests/unit/utils.py @@ -17,6 +17,7 @@ from concurrent.futures import Future from cassandra.cluster import Session +from cassandra.driver_config import DriverConfigReporter def mock_session_pools(f): @@ -32,3 +33,16 @@ def wrapper(*args, **kwargs): mocked_add_or_renew_pool.return_value = future f(*args, **kwargs) return wrapper + + +class ThrowingReporter(DriverConfigReporter): + """ + A driver configuration reporter whose report cannot be built. + + Shared because two suites need it: the reporter's own tests, for the guard + that drops a report it cannot build, and the connection tests, for the + guarantee that such a failure leaves the STARTUP frame otherwise intact + instead of failing the connection. + """ + def _build_report(self): + raise ValueError("simulated failure while building the report") From e2db608b914646ca32257e371d4af1bf383976e0 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Thu, 20 Aug 2026 10:46:37 +0200 Subject: [PATCH 147/200] DRIVER-950: Report SESSION_ID and DRIVER_CONFIG in the STARTUP options A connection now takes a session id, reported by every connection since correlating a cluster's connections is the whole point of it, and a configuration reporter, consulted only on the control connection because the configuration is identical across them. Both go in after the application's own options, and the four keys the driver owns are cleared from those first, so an ApplicationInfo can neither misreport the driver nor break the correlation. CQL_VERSION needs no clearing, since StartupMessage writes it after the options map. The application info is one object shared by every connection of a cluster, so warning on each would repeat hosts x shards + 1 times per connect() and again on every pool replacement. The warning is emitted on the control connection, established once and before the pools, and left at debug elsewhere. --- cassandra/connection.py | 60 ++++++++- tests/unit/test_connection.py | 224 +++++++++++++++++++++++++++++++++- 2 files changed, 280 insertions(+), 4 deletions(-) diff --git a/cassandra/connection.py b/cassandra/connection.py index 24b07f89e6..af95891a3b 100644 --- a/cassandra/connection.py +++ b/cassandra/connection.py @@ -31,6 +31,8 @@ from typing import Any, Dict, Optional, Tuple, Union from cassandra.application_info import ApplicationInfoBase +from cassandra.driver_config import (DriverConfigReporter, DRIVER_CONFIG_OPTION, + SESSION_ID_OPTION) from cassandra.client_routes import _ClientRoutesHandler from cassandra.protocol_features import ProtocolFeatures @@ -878,6 +880,16 @@ class Connection(object): features = None _application_info: Optional[ApplicationInfoBase] = None + # Identifier of the cluster this connection belongs to, reported in the + # SESSION_ID startup option so that all of a cluster's connections can be + # correlated with each other in the clients table. + _session_id = None + + # Set on every connection, but only used by the control connection, which is + # the only one reporting the driver configuration. Left as None when the + # cluster has configuration reporting disabled. + _driver_config_reporter: Optional[DriverConfigReporter] = None + @property def _iobuf(self): # backward compatibility, to avoid any change in the reactors @@ -888,7 +900,8 @@ def __init__(self, host='127.0.0.1', port=9042, authenticator=None, cql_version=None, protocol_version=ProtocolVersion.MAX_SUPPORTED, is_control_connection=False, user_type_map=None, connect_timeout=None, allow_beta_protocol_version=False, no_compact=False, ssl_context=None, owning_pool=None, shard_id=None, total_shards=None, - on_orphaned_stream_released=None, application_info: Optional[ApplicationInfoBase] = None): + on_orphaned_stream_released=None, application_info: Optional[ApplicationInfoBase] = None, + session_id=None, driver_config_reporter: Optional[DriverConfigReporter] = None): # TODO next major rename host to endpoint and remove port kwarg. self.endpoint = host if isinstance(host, EndPoint) else DefaultEndPoint(host, port) @@ -912,6 +925,8 @@ def __init__(self, host='127.0.0.1', port=9042, authenticator=None, self.orphaned_request_ids = set() self._on_orphaned_stream_released = on_orphaned_stream_released self._application_info = application_info + self._session_id = session_id + self._driver_config_reporter = driver_config_reporter if ssl_options: self.ssl_options.update(self.endpoint.ssl_options or {}) @@ -1507,6 +1522,49 @@ def _handle_options_response(self, options_response): self._application_info.add_startup_options(options) self.features.add_startup_options(options) + # Driver-owned options go in after the application's, so that they can + # overwrite them and never the other way round. An application that set + # SESSION_ID would break correlating a cluster's connections in the + # clients table, which is the only reason the option exists; one that set + # DRIVER_CONFIG would have an operator read its value as the driver's + # description of itself; and one that set DRIVER_NAME or DRIVER_VERSION + # would misreport the driver for the life of the connection, to the same + # operator and in the same row. + # + # They are cleared rather than merely overwritten, so that ownership does + # not depend on this connection having something to say: a pool + # connection reports no configuration at all, and neither does a control + # connection whose report was dropped or turned off. DRIVER_NAME and + # DRIVER_VERSION are then put back by _send_startup_message, the only + # place that knows them. CQL_VERSION needs no entry here: StartupMessage + # writes it after the options map, so it cannot be overridden either. + for owned_key in (SESSION_ID_OPTION, DRIVER_CONFIG_OPTION, + 'DRIVER_NAME', 'DRIVER_VERSION'): + if options.pop(owned_key, None) is not None: + # The application info is one object shared by every connection of + # a cluster, so an offending key is seen on all of them: warning + # on each would mean hosts x shards + 1 lines per connect(), and + # as many again on every pool replacement or control connection + # reconnect, for a misconfiguration that is in the application's + # code and identical on all of them. + # + # Warn on the control connection, which is established once per + # cluster and before the pools, and keep the rest at debug for + # whoever is looking at a specific connection. + level = logging.WARNING if self.is_control_connection else logging.DEBUG + log.log(level, + "Ignoring the application-supplied %s startup option on %s: " + "the option is reserved for the driver", owned_key, self.endpoint) + + if self._session_id is not None: + options[SESSION_ID_OPTION] = str(self._session_id) + + # The configuration is the same for every connection of a cluster, so + # only the control connection reports it. A reporter left as None means + # the cluster has configuration reporting disabled. + if self.is_control_connection and self._driver_config_reporter is not None: + self._driver_config_reporter.add_startup_options(options) + if self.cql_version: if self.cql_version not in supported_cql_versions: raise ProtocolError( diff --git a/tests/unit/test_connection.py b/tests/unit/test_connection.py index ee9dac4aa1..5962db1189 100644 --- a/tests/unit/test_connection.py +++ b/tests/unit/test_connection.py @@ -13,22 +13,27 @@ # limitations under the License. import itertools import unittest +import uuid from io import BytesIO import time from threading import Lock from unittest.mock import Mock, ANY, call, patch from cassandra import OperationTimedOut +from cassandra.application_info import ApplicationInfoBase from cassandra.cluster import Cluster from cassandra.connection import (Connection, HEADER_DIRECTION_TO_CLIENT, ProtocolError, locally_supported_compressions, ConnectionHeartbeat, HeartbeatFuture, _Frame, Timer, TimerManager, ConnectionException, ConnectionShutdown, DefaultEndPoint, ShardAwarePortGenerator, - DRIVER_NAME) + DRIVER_NAME, DRIVER_VERSION) +from cassandra.driver_config import (DriverConfigReporter, DRIVER_CONFIG_OPTION, + DRIVER_CONFIG_SCHEMA_VERSION, SESSION_ID_OPTION) from cassandra.marshal import uint8_pack, uint32_pack, int32_pack from cassandra.protocol import (write_stringmultimap, write_int, write_string, - SupportedMessage, ProtocolHandler, ResultMessage, - RESULT_KIND_SET_KEYSPACE) + read_stringmap, SupportedMessage, ProtocolHandler, + ResultMessage, RESULT_KIND_SET_KEYSPACE) +from tests.unit.utils import ThrowingReporter from tests.util import wait_until, assertRegex import pytest @@ -409,6 +414,219 @@ def test_wait_for_responses_shutdown_includes_last_error(self): assert "Bad file descriptor" in error_message +class StartupOptionsTest(unittest.TestCase): + """ + Covers the options the driver puts in the STARTUP frame, by driving a + connection through the SUPPORTED response that triggers it and reading the + frame it hands to send_msg. + """ + + SESSION_ID = uuid.UUID('91b0b1a2-0000-4000-8000-000000000001') + + def startup_options(self, **kwargs): + c = Connection(DefaultEndPoint('1.2.3.4'), **kwargs) + c._socket = Mock() + c.send_msg = Mock() + c.defunct = Mock() + + c._handle_options_response( + SupportedMessage(cql_versions=['3.0.3'], options={'COMPRESSION': []})) + + c.defunct.assert_not_called() + c.send_msg.assert_called_once() + return c.send_msg.call_args[0][0].options + + def test_session_id_is_reported(self): + options = self.startup_options(session_id=self.SESSION_ID) + + assert options[SESSION_ID_OPTION] == str(self.SESSION_ID) + + def test_session_id_is_absent_when_not_configured(self): + """ + Connections built outside of a Cluster (lower-level integrations, tests) + have no cluster to be correlated with, so they report no SESSION_ID + rather than an empty one. + """ + options = self.startup_options() + + assert SESSION_ID_OPTION not in options + + def test_application_info_cannot_override_the_session_id(self): + """ + SESSION_ID is driver-owned: it is what correlates a cluster's connections + in the clients table, so an application-supplied value must not win. + """ + class SpoofingApplicationInfo(ApplicationInfoBase): + def add_startup_options(self, options): + options[SESSION_ID_OPTION] = 'not-the-session-id' + options['APPLICATION_NAME'] = 'app' + + options = self.startup_options(session_id=self.SESSION_ID, + application_info=SpoofingApplicationInfo()) + + assert options[SESSION_ID_OPTION] == str(self.SESSION_ID) + # Keys the driver does not own must still come through. + assert options['APPLICATION_NAME'] == 'app' + + def test_application_info_cannot_override_the_driver_config(self): + """ + DRIVER_CONFIG is driver-owned too: an operator reading it out of the + clients table must be reading the driver's description of itself, not an + application's. That has to hold on the connections and in the + configurations that report none of their own, which is where merely + writing it last would leave the application's value standing. + """ + class SpoofingApplicationInfo(ApplicationInfoBase): + def add_startup_options(self, options): + options[DRIVER_CONFIG_OPTION] = '{"version":999,"spoofed":true}' + options['APPLICATION_NAME'] = 'app' + + # Four independent guarantees, each in its own subtest: run sequentially + # the first regression would hide the other three, and which of them + # break is what says where the hole is. + ABSENT = object() + cases = [ + ("a pool connection reports no configuration at all", + {'driver_config_reporter': DriverConfigReporter()}, + ABSENT), + ("nor does a control connection with reporting disabled", + {'is_control_connection': True}, + ABSENT), + ("a dropped report is not a hole for one either", + {'is_control_connection': True, 'driver_config_reporter': ThrowingReporter()}, + ABSENT), + ("the driver's own report wins where there is one", + {'is_control_connection': True, 'driver_config_reporter': DriverConfigReporter()}, + '{"version":%d}' % DRIVER_CONFIG_SCHEMA_VERSION), + ] + + for description, kwargs, expected in cases: + with self.subTest(description): + options = self.startup_options(session_id=self.SESSION_ID, + application_info=SpoofingApplicationInfo(), + **kwargs) + if expected is ABSENT: + assert DRIVER_CONFIG_OPTION not in options + else: + assert options[DRIVER_CONFIG_OPTION] == expected + # Keys the driver does not own must still come through. + assert options['APPLICATION_NAME'] == 'app' + + def test_application_info_cannot_override_the_driver_identity(self): + """ + DRIVER_NAME and DRIVER_VERSION are driver-owned for the same reason as + the two options above: they land in the same clients-table row, read by + the same operator, and a spoofed value would misreport the driver for the + life of the connection. + + They are the pair that made the ordering matter: _send_startup_message + merges the application's options *after* its own literals, so before they + were cleared here an application-supplied value won. + """ + class SpoofingApplicationInfo(ApplicationInfoBase): + def add_startup_options(self, options): + options['DRIVER_NAME'] = 'not-the-driver' + options['DRIVER_VERSION'] = '0.0.0' + options['APPLICATION_NAME'] = 'app' + + options = self.startup_options(application_info=SpoofingApplicationInfo()) + + assert options['DRIVER_NAME'] == DRIVER_NAME + assert options['DRIVER_VERSION'] == DRIVER_VERSION + # Keys the driver does not own must still come through. + assert options['APPLICATION_NAME'] == 'app' + + def test_ignored_option_is_warned_about_once_per_cluster(self): + """ + The offending key is on every connection of the cluster, since they share + one application info object, so warning on each would mean + hosts x shards + 1 lines per connect() and as many again on every pool + replacement. The control connection, established once and before the + pools, carries the warning; the rest stay at debug. + """ + class SpoofingApplicationInfo(ApplicationInfoBase): + def add_startup_options(self, options): + options['DRIVER_NAME'] = 'not-the-driver' + + with self.assertLogs('cassandra.connection', level='DEBUG') as captured: + self.startup_options(is_control_connection=True, + application_info=SpoofingApplicationInfo()) + assert [r.getMessage() for r in captured.records if r.levelname == 'WARNING'] == [ + "Ignoring the application-supplied DRIVER_NAME startup option on 1.2.3.4:9042: " + "the option is reserved for the driver"] + + with self.assertLogs('cassandra.connection', level='DEBUG') as captured: + self.startup_options(application_info=SpoofingApplicationInfo()) + assert [r.levelname for r in captured.records if 'DRIVER_NAME' in r.getMessage()] == ['DEBUG'] + + def test_application_info_cannot_override_the_cql_version(self): + """ + CQL_VERSION is driver-owned as well, but needs no clearing: StartupMessage + writes it after the options map. Pinned here so that the reason the key is + absent from the cleared set stays true. + """ + class SpoofingApplicationInfo(ApplicationInfoBase): + def add_startup_options(self, options): + options['CQL_VERSION'] = '9.9.9' + + c = Connection(DefaultEndPoint('1.2.3.4'), + application_info=SpoofingApplicationInfo()) + c._socket = Mock() + c.send_msg = Mock() + c.defunct = Mock() + c._handle_options_response( + SupportedMessage(cql_versions=['3.0.3'], options={'COMPRESSION': []})) + c.defunct.assert_not_called() + + buf = BytesIO() + c.send_msg.call_args[0][0].send_body(buf, c.protocol_version) + buf.seek(0) + assert read_stringmap(buf)['CQL_VERSION'] == '3.0.3' + + def test_driver_config_is_reported_on_the_control_connection(self): + options = self.startup_options(is_control_connection=True, + driver_config_reporter=DriverConfigReporter()) + + assert options[DRIVER_CONFIG_OPTION] == '{"version":%d}' % DRIVER_CONFIG_SCHEMA_VERSION + + def test_driver_config_is_not_reported_on_a_regular_connection(self): + """ + The configuration is the same for every connection of a cluster, so only + the control connection reports it; the pool connections still report the + session id that ties them to it. + """ + options = self.startup_options(session_id=self.SESSION_ID, + driver_config_reporter=DriverConfigReporter()) + + assert SESSION_ID_OPTION in options + assert DRIVER_CONFIG_OPTION not in options + + def test_driver_config_is_not_reported_without_a_reporter(self): + """ + A reporter left as None is how a Cluster with + driver_config_reporting_enabled=False reaches its connections. The + session id is documented not to be affected by that setting. + """ + options = self.startup_options(is_control_connection=True, + session_id=self.SESSION_ID) + + assert options[SESSION_ID_OPTION] == str(self.SESSION_ID) + assert DRIVER_CONFIG_OPTION not in options + + def test_a_failing_reporter_does_not_break_the_handshake(self): + """ + Reporting is a diagnostic aid: a reporter that cannot produce a report + must leave the STARTUP frame otherwise intact rather than fail the + connection. + """ + options = self.startup_options(is_control_connection=True, + session_id=self.SESSION_ID, + driver_config_reporter=ThrowingReporter()) + + assert DRIVER_CONFIG_OPTION not in options + assert options[SESSION_ID_OPTION] == str(self.SESSION_ID) + + @patch('cassandra.connection.ConnectionHeartbeat._raise_if_stopped') class ConnectionHeartbeatTest(unittest.TestCase): From c4dd6368e2d9081c94af200805431cd819eb9ce8 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Thu, 20 Aug 2026 10:46:52 +0200 Subject: [PATCH 148/200] DRIVER-950: Give every Cluster a session id and a config reporter The id is scoped to the Cluster rather than the Session: the control connection, the one that describes the configuration, belongs to the Cluster, so a per-Session id would leave it uncorrelated with the connections it describes. It is exposed read-only as Cluster.session_id, so an application can match its own logs against the clients table instead of correlating by address and port, and is unrelated to Session.session_id, which identifies a Session to Insights and is never sent to the cluster. driver_config_reporting_enabled is read when a connection is opened, like compression and no_compact beside it, so changing it after construction takes effect on later connections. The session id carries no configuration and is deliberately unaffected by it. --- cassandra/cluster.py | 71 ++++++++++++++++++++++++++++- tests/unit/test_cluster.py | 93 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+), 1 deletion(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 808d5804f5..7260bd08b6 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -93,6 +93,7 @@ from cassandra.datastax.graph.query import _request_timeout_key, _GraphSONContextRowFactory from cassandra.datastax import cloud as dscloud from cassandra.application_info import ApplicationInfoBase +from cassandra.driver_config import DriverConfigReporter try: from weakref import WeakSet @@ -1020,6 +1021,47 @@ def default_retry_policy(self, policy): documentation for :meth:`Session.timestamp_generator`. """ + driver_config_reporting_enabled = True + """ + A boolean indicating whether the driver describes its effective + configuration to the cluster while setting up the control connection, so + that operators can inspect the settings of a client while investigating an + incident. The description is sent as the ``DRIVER_CONFIG`` startup option + and ScyllaDB exposes it in the ``client_options`` column of its clients + table. + + :attr:`~.Cluster.session_id`, which every connection reports, is not + affected by this setting. + + Read when a connection is opened, so changing it takes effect on the + connections established afterwards and leaves the open ones alone. + + Defaults to :const:`True`. + """ + + _session_id = None + + @property + def session_id(self): + """ + A :class:`uuid.UUID` identifying this ``Cluster``, generated when it is + created and never changing afterwards. + + Every connection this ``Cluster`` opens -- the control connection as + well as the pools of each of its :class:`~.Session` objects -- reports + it to the cluster as the ``SESSION_ID`` startup option, where ScyllaDB + exposes it in the ``client_options`` column of its clients table. + Logging it, or attaching it to a support bundle, is what allows + client-side observations to be matched against those rows instead of + correlating them by address and port. + + The option is named after the convention shared with the other ScyllaDB + drivers, where a "session" is what this driver calls a ``Cluster``. It is + unrelated to :attr:`.Session.session_id`, which identifies a ``Session`` + within the client and is never reported to the cluster. + """ + return self._session_id + cloud = None """ A dict of the cloud configuration. Example:: @@ -1178,7 +1220,8 @@ def __init__(self, column_encryption_policy=None, application_info:Optional[ApplicationInfoBase]=None, client_routes_config:Optional[ClientRoutesConfig]=None, - allow_control_connection_query_fallback:Optional[ControlConnectionQueryFallback]=ControlConnectionQueryFallback.Disabled + allow_control_connection_query_fallback:Optional[ControlConnectionQueryFallback]=ControlConnectionQueryFallback.Disabled, + driver_config_reporting_enabled=True ): """ ``executor_threads`` defines the number of threads in a pool for handling asynchronous tasks such as @@ -1467,6 +1510,18 @@ def __init__(self, from cassandra.metrics import Metrics self.metrics = Metrics(weakref.proxy(self)) + # Both are read by _make_connection_kwargs, so they have to be in place + # before anything can open a connection. + # + # The session id is generated unconditionally: every connection reports + # it, whatever driver_config_reporting_enabled is set to. + self._session_id = uuid.uuid4() + self.driver_config_reporting_enabled = driver_config_reporting_enabled + # Built whatever the flag says, so that the flag is the only thing that + # decides whether a connection reports: see _make_connection_kwargs. The + # reporter holds no state, so an unused one costs nothing. + self._driver_config_reporter = DriverConfigReporter() + self.control_connection = ControlConnection( self, self.control_connection_timeout, self.schema_event_refresh_window, self.topology_event_refresh_window, @@ -1643,6 +1698,16 @@ def _make_connection_kwargs(self, endpoint, kwargs_dict): kwargs_dict.setdefault('no_compact', self.no_compact) kwargs_dict.setdefault('application_info', self.application_info) + # Assigned rather than defaulted, unlike everything above: both describe + # this Cluster to the server, so a caller must not be able to substitute + # them. A connection reports whatever session id it is handed, which + # would break the correlation the option exists for, and a reporter + # passed in here would report a configuration that + # driver_config_reporting_enabled turned off. + kwargs_dict['session_id'] = self.session_id + kwargs_dict['driver_config_reporter'] = \ + self._driver_config_reporter if self.driver_config_reporting_enabled else None + return kwargs_dict def protocol_downgrade(self, host_endpoint, previous_version): @@ -2528,6 +2593,10 @@ def default_serial_consistency_level(self, cl): session_id = None """ A UUID that uniquely identifies this Session. This will be generated automatically. + + This is not the identifier reported to the cluster in the ``SESSION_ID`` + startup option: that one is per-:class:`~.Cluster` and shared by every + connection, see :attr:`.Cluster.session_id`. """ _lock = None diff --git a/tests/unit/test_cluster.py b/tests/unit/test_cluster.py index f3533352a4..35dc354465 100644 --- a/tests/unit/test_cluster.py +++ b/tests/unit/test_cluster.py @@ -26,6 +26,7 @@ from cassandra.cluster import _Scheduler, Session, Cluster, ResultSet, SchemaAgreementScope, ControlConnectionQueryFallback, default_lbp_factory, \ ExecutionProfile, _ConfigMode, EXEC_PROFILE_DEFAULT from cassandra.connection import ConnectionBusy, ConnectionException +from cassandra.driver_config import DriverConfigReporter from cassandra.pool import Host from cassandra.policies import HostDistance, RetryPolicy, RoundRobinPolicy, DowngradingConsistencyRetryPolicy, SimpleConvictionPolicy from cassandra.query import SimpleStatement, named_tuple_factory, tuple_factory @@ -287,6 +288,98 @@ def test_connection_factory_passes_compression_kwarg(self): assert factory.call_args.kwargs['compression'] == expected assert cluster.compression == expected + def test_session_id_is_stable_and_unique_per_cluster(self): + cluster = Cluster() + other = Cluster() + self.addCleanup(cluster.shutdown) + self.addCleanup(other.shutdown) + + assert isinstance(cluster.session_id, uuid.UUID) + assert cluster.session_id == cluster.session_id + assert cluster.session_id != other.session_id + + def test_session_id_is_read_only(self): + """ + Every connection reports it at STARTUP, so it cannot change once any of + them is open without breaking the correlation it exists for. + """ + cluster = Cluster() + self.addCleanup(cluster.shutdown) + + with pytest.raises(AttributeError): + cluster.session_id = uuid.uuid4() + + def test_connection_factory_reports_the_session_id_and_the_configuration(self): + endpoint = Mock(address='127.0.0.1') + with patch.object(Cluster.connection_class, 'factory', autospec=True, return_value='connection') as factory: + cluster = Cluster() + self.addCleanup(cluster.shutdown) + cluster.connection_factory(endpoint) + + assert factory.call_args.kwargs['session_id'] == cluster.session_id + assert isinstance(factory.call_args.kwargs['driver_config_reporter'], DriverConfigReporter) + + def test_driver_config_reporting_can_be_toggled_after_construction(self): + """ + The flag is a plain published attribute, so it is read when a connection + is opened rather than captured at construction: setting it either way + used to be silently inert, in both directions. + """ + endpoint = Mock(address='127.0.0.1') + + def reporter_for(cluster): + with patch.object(Cluster.connection_class, 'factory', autospec=True, + return_value='connection') as factory: + cluster.connection_factory(endpoint) + return factory.call_args.kwargs['driver_config_reporter'] + + cluster = Cluster() + self.addCleanup(cluster.shutdown) + assert reporter_for(cluster) is not None + cluster.driver_config_reporting_enabled = False + assert reporter_for(cluster) is None + + # And back on again: a cluster built with reporting off must not be + # permanently unable to report. Registered before the rebinding, since + # afterwards the first cluster is no longer reachable to shut down. + cluster = Cluster(driver_config_reporting_enabled=False) + self.addCleanup(cluster.shutdown) + assert reporter_for(cluster) is None + cluster.driver_config_reporting_enabled = True + assert isinstance(reporter_for(cluster), DriverConfigReporter) + + def test_connection_factory_passes_no_reporter_when_reporting_is_disabled(self): + """ + A reporter left as None is how the setting reaches the connections. The + session id is documented not to be affected by it. + """ + endpoint = Mock(address='127.0.0.1') + with patch.object(Cluster.connection_class, 'factory', autospec=True, return_value='connection') as factory: + cluster = Cluster(driver_config_reporting_enabled=False) + self.addCleanup(cluster.shutdown) + cluster.connection_factory(endpoint) + + assert cluster.driver_config_reporting_enabled is False + assert factory.call_args.kwargs['driver_config_reporter'] is None + assert factory.call_args.kwargs['session_id'] == cluster.session_id + + def test_connection_factory_ignores_a_caller_supplied_session_id_and_reporter(self): + """ + Both describe the Cluster to the server, so they are assigned rather than + defaulted from the caller's kwargs: nothing may substitute the id that + correlates this cluster's connections, nor a reporter the cluster's own + setting turned off. + """ + endpoint = Mock(address='127.0.0.1') + with patch.object(Cluster.connection_class, 'factory', autospec=True, return_value='connection') as factory: + cluster = Cluster(driver_config_reporting_enabled=False) + self.addCleanup(cluster.shutdown) + cluster.connection_factory(endpoint, session_id=uuid.uuid4(), + driver_config_reporter=DriverConfigReporter()) + + assert factory.call_args.kwargs['session_id'] == cluster.session_id + assert factory.call_args.kwargs['driver_config_reporter'] is None + class SchedulerTest(unittest.TestCase): # TODO: this suite could be expanded; for now just adding a test covering a ticket From d854d01e59b65610c8ec142215c5828c10e80712 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Thu, 20 Aug 2026 10:46:52 +0200 Subject: [PATCH 149/200] DRIVER-950: Add integration tests for the reported startup options Check against a live ScyllaDB that every connection of a cluster reports the same session id, that distinct clusters report distinct ones, that the configuration report appears exactly once and only on the control connection, and that disabling reporting removes it while leaving the id. The tests run on a single node so the clients table, which lists only the connections made to the node serving the query, is complete. Each claim waits for the rows it rests on: a connection is listed only once the server has registered it, and an absence or a "reported exactly once" holds trivially over rows that have not arrived yet. Share the lookup as get_client_options, which also skips the rows the server has not filled in, and catch only InvalidRequest, the answer an absent table gives: a bare except reported a timeout on system.clients as a failure of system_views.clients. --- tests/integration/__init__.py | 20 ++ .../standard/test_driver_config.py | 223 ++++++++++++++++++ 2 files changed, 243 insertions(+) create mode 100644 tests/integration/standard/test_driver_config.py diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py index 6118d961da..5b6986d019 100644 --- a/tests/integration/__init__.py +++ b/tests/integration/__init__.py @@ -671,6 +671,26 @@ def is_scylla_enterprise(version: Version) -> bool: return version > Version('2000.1.1') +def get_client_options(session): + """ + The ``client_options`` of every connection listed in the cluster's clients + table, skipping the rows the server has not filled in yet. + + The table has lived in both ``system`` and ``system_views`` across versions, + so try each in turn. Only :exc:`.InvalidRequest` is caught, which is what an + absent table answers with: catching more would report a timeout or a + ``NoHostAvailable`` on the first query as a failure of the second, and this + runs inside polling loops where that would repeat. + """ + try: + rows = list(session.execute("SELECT client_options FROM system.clients")) + except InvalidRequest: + rows = list(session.execute("SELECT client_options FROM system_views.clients")) + # Indexed rather than named: the query selects the one column, so this works + # whatever row factory the caller's session is using. + return [row[0] for row in rows if row[0]] + + def xfail_scylla_version_lt(reason, scylla_version, *args, **kwargs): """ It is used to mark tests that are going to fail on certain scylla versions. diff --git a/tests/integration/standard/test_driver_config.py b/tests/integration/standard/test_driver_config.py new file mode 100644 index 0000000000..8728c22b54 --- /dev/null +++ b/tests/integration/standard/test_driver_config.py @@ -0,0 +1,223 @@ +# Copyright 2026 ScyllaDB, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import time +import unittest + +from cassandra.driver_config import (DRIVER_CONFIG_OPTION, DRIVER_CONFIG_SCHEMA_VERSION, + SESSION_ID_OPTION) +from tests.integration import (TestCluster, get_client_options, use_single_node, remove_cluster, + xfail_scylla_version_lt) + + +def setup_module(): + # A single node keeps the clients table, which lists only the connections + # made to the node serving the query, complete: every connection of the + # cluster under test is made to that one node. + use_single_node() + + +def teardown_module(): + remove_cluster() + + +CONNECTION_WAIT_TIMEOUT = 30 + + +def _expected_connection_count(session): + """ + Number of connections the driver believes it has open: the control + connection plus every live pool connection, one per shard on a shard aware + cluster. This is what "every connection of this cluster" has to be counted + against; asserting over whichever connections happen to be listed says + nothing about the ones that are not. + """ + return 1 + sum(state['open_count'] for state in session.get_pool_state().values()) + + +def _settled_connection_count(session, timeout=CONNECTION_WAIT_TIMEOUT): + """ + Same count, taken once the pools have stopped filling. + + ``Cluster.connect(wait_for_all_pools=True)`` waits only for each pool's + first connection. On a shard aware cluster HostConnection opens that one, + learns the shard count from it, and then submits a connection per remaining + shard to the session executor, returning before any of them are up. Counting + straight after connect would therefore set the bar at one connection per + host and let the assertions pass without ever looking at the shard + connections. + + A connection that fails to open for good would keep the count below the + shard count forever, so this waits for the count to stop moving rather than + for a particular value, and returns whatever the driver ended up with. + """ + deadline = time.time() + timeout + # Two unchanged reads in a row, so that a gap between two shard connections + # coming up is not mistaken for the pools having settled. + settled_reads, previous = 0, None + while True: + count = _expected_connection_count(session) + settled_reads = settled_reads + 1 if count == previous else 0 + previous = count + if settled_reads >= 2 or time.time() >= deadline: + return count + time.sleep(0.5) + + +def _wait_for_connections(session, session_id, count, timeout=CONNECTION_WAIT_TIMEOUT): + """ + Polls the clients table until at least ``count`` connections report + ``session_id``, and returns the client options of the ones that do. + + ``Cluster.connect(wait_for_all_pools=True)`` waits for the pools to be + created, but a connection shows up here only once the server has registered + it, so the rows arrive later than the connections do. + + Returns a short list if the timeout expires first rather than asserting, so + that the count stays the caller's claim to make: an "absent everywhere" or a + "reported exactly once" holds trivially over a list that is short only + because the rows had not appeared yet. + """ + deadline = time.time() + timeout + while True: + options = [o for o in get_client_options(session) if o.get(SESSION_ID_OPTION) == session_id] + if len(options) >= count or time.time() >= deadline: + return options + time.sleep(0.5) + + +def _assert_listed(options, count, session_id, timeout=CONNECTION_WAIT_TIMEOUT): + assert len(options) >= count, \ + "only %d of %d connections with SESSION_ID %s were listed within %ss" % ( + len(options), count, session_id, timeout) + + +@xfail_scylla_version_lt(reason='scylladb/scylla-enterprise#5467 - system.client_options is not yet supported', + scylla_version="2026.1.0") +class DriverConfigReportingTest(unittest.TestCase): + def test_every_connection_reports_the_session_id(self): + """ + The session id is what correlates a cluster's connections with each + other in the clients table, so all of them must report the one the + cluster exposes -- not merely some of them. + """ + cluster = TestCluster() + try: + session = cluster.connect(wait_for_all_pools=True) + session_id = str(cluster.session_id) + expected = _settled_connection_count(session) + + options = _wait_for_connections(session, session_id, count=expected) + + # The rows were selected by session id, so the count is the claim: + # as many connections carry it as the cluster has open. More is + # fine, a connection that has already been closed lingers in the + # table; fewer means one of them went out without the id. + _assert_listed(options, expected, session_id) + finally: + cluster.shutdown() + + def test_distinct_clusters_report_distinct_session_ids(self): + cluster = TestCluster() + other_cluster = TestCluster() + try: + session = cluster.connect(wait_for_all_pools=True) + other_cluster.connect(wait_for_all_pools=True) + + session_id = str(cluster.session_id) + other_session_id = str(other_cluster.session_id) + assert session_id != other_session_id + + # Both clusters reach the same node, so either session can read the + # rows of both. Each id is waited for rather than read straight out + # of one snapshot: connect() returns once the pools exist on the + # client, while the rows appear only once the server has registered + # the connections, so whichever cluster registered last would + # intermittently be missing. + # + # One row per cluster is the whole claim here -- that the two ids + # both reach the server and differ. Counting every connection of a + # cluster is what test_every_connection_reports_the_session_id is + # for. + for wanted in (session_id, other_session_id): + _assert_listed(_wait_for_connections(session, wanted, count=1), 1, wanted) + finally: + other_cluster.shutdown() + cluster.shutdown() + + def test_only_the_control_connection_reports_the_driver_config(self): + """ + The configuration is the same for every connection of a cluster, so it is + reported once, by the control connection, to keep the other STARTUP + frames small. + """ + cluster = TestCluster() + try: + session = cluster.connect(wait_for_all_pools=True) + session_id = str(cluster.session_id) + + # Every connection of the cluster, not merely two of them. Two rows + # can both be pool connections, and the control connection is the + # only one that ever reports, so its row missing from the snapshot + # would empty `reports` and fail this test with nothing broken. + expected = _settled_connection_count(session) + options = _wait_for_connections(session, session_id, count=expected) + _assert_listed(options, expected, session_id) + + reports = [o[DRIVER_CONFIG_OPTION] for o in options if DRIVER_CONFIG_OPTION in o] + + # Rows can outlive the connections they describe, so the table may + # list more than the cluster has open. Those extra rows are not + # harmless bystanders: a control connection re-established during + # this test leaves a closed row carrying this same session id and a + # DRIVER_CONFIG of its own, so a second report does not by itself + # mean a pool connection produced one. + # + # The exact count is kept regardless, because relaxing it would let + # through the regression this test exists for, and a reconnect + # against a healthy single node within the seconds it runs is not + # expected. The message says so, so that a failure here can be told + # apart from the regression. + assert len(reports) == 1, \ + ("expected exactly one connection to report %s, got %d. If the control " + "connection reconnected during this test, the closed one may still be " + "listed with a report of its own." % (DRIVER_CONFIG_OPTION, len(reports))) + assert json.loads(reports[0]) == {'version': DRIVER_CONFIG_SCHEMA_VERSION} + finally: + cluster.shutdown() + + def test_driver_config_is_not_reported_when_disabled(self): + """ + With reporting disabled not even the control connection reports + DRIVER_CONFIG, while the session id, which the setting is documented not + to affect, is still reported by every connection. + """ + cluster = TestCluster(driver_config_reporting_enabled=False) + try: + session = cluster.connect(wait_for_all_pools=True) + session_id = str(cluster.session_id) + + # Every connection of the cluster, not merely two of them. Two rows + # can both be pool connections, which never report whatever this + # setting says, so the absence below would hold over them even with + # the control connection reporting away: the one case this test + # exists to catch. + expected = _settled_connection_count(session) + options = _wait_for_connections(session, session_id, count=expected) + _assert_listed(options, expected, session_id) + + assert all(DRIVER_CONFIG_OPTION not in o for o in options) + finally: + cluster.shutdown() From 3583fdcfff75b2cc6ee2f33061c1c18ed2f9b9ff Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Thu, 20 Aug 2026 10:47:06 +0200 Subject: [PATCH 150/200] DRIVER-950: Document client identification and configuration reporting Add a Scylla-specific guide section on SESSION_ID and DRIVER_CONFIG, how to read them back out of the clients table, and how they relate to the application_info options an application supplies itself. Spell out the two points that are easy to get wrong: the option is named after the convention shared with the other ScyllaDB drivers, where a session is what this driver calls a Cluster, so it is unrelated to Session.session_id; and driver_config_reporting_enabled turns off the configuration report only, never the session id. Also list the two new Cluster attributes in the API reference, and record in the changelog that the startup options describing the driver are no longer the application's to set. --- CHANGELOG.rst | 18 +++++++++ docs/api/cassandra/cluster.rst | 5 +++ docs/scylla-specific.rst | 73 ++++++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2a02f1ac54..068be2e048 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -3,12 +3,30 @@ Unreleased Features -------- +* Report the driver's identity and configuration in the ``STARTUP`` options, where + ScyllaDB exposes them in the ``client_options`` column of its clients table + (DRIVER-950). Every connection now sends ``SESSION_ID``, a UUID identifying the + ``Cluster`` it belongs to and readable from the new ``Cluster.session_id``, so all of + a client's connections can be correlated with each other and with its own logs. The + control connection additionally sends ``DRIVER_CONFIG``, a JSON description of the + effective configuration, which for now carries only the schema version it follows. + Reporting the configuration can be turned off with the new + ``Cluster(driver_config_reporting_enabled=False)``; ``SESSION_ID`` is unaffected by + that setting. Reporting is best effort and never prevents a connection from being + established. * Negotiate and implement the ``SCYLLA_USE_METADATA_ID`` protocol extension: prepared statements skip re-sending result metadata on EXECUTE, and the driver automatically refreshes cached metadata when the server detects a schema change (DRIVER-153) Others ------ +* The ``STARTUP`` options that describe the driver itself are no longer the + application's to set. An ``ApplicationInfoBase.add_startup_options`` that sets + ``DRIVER_NAME``, ``DRIVER_VERSION``, ``SESSION_ID`` or ``DRIVER_CONFIG`` now has that + value dropped, with a warning naming the option; keys the driver does not own still + come through unchanged. Previously ``DRIVER_NAME`` and ``DRIVER_VERSION`` could be + overridden, which misreported the driver to the server for the life of the connection + and, in the clients table, to the operator reading the row. * ``PreparedStatement.result_metadata`` and ``PreparedStatement.result_metadata_id`` are now read-only. They are replaced together by ``PreparedStatement.update_result_metadata()``, so a request can never observe a metadata diff --git a/docs/api/cassandra/cluster.rst b/docs/api/cassandra/cluster.rst index 44b7b63f67..cf9cc59fc4 100644 --- a/docs/api/cassandra/cluster.rst +++ b/docs/api/cassandra/cluster.rst @@ -30,6 +30,11 @@ Clusters and Sessions .. autoattribute:: address_translator + .. autoattribute:: session_id + + .. autoattribute:: driver_config_reporting_enabled + :annotation: = True + .. autoattribute:: metrics_enabled .. autoattribute:: metrics diff --git a/docs/scylla-specific.rst b/docs/scylla-specific.rst index e7cb986b6d..92df047530 100644 --- a/docs/scylla-specific.rst +++ b/docs/scylla-specific.rst @@ -290,3 +290,76 @@ directly from the data. For full protocol details see the ScyllaDB CQL protocol extensions documentation: https://github.com/scylladb/scylladb/blob/master/docs/dev/protocol-extensions.md + + +Client identification and configuration reporting +------------------------------------------------- + +The driver describes itself to the cluster in the CQL ``STARTUP`` options of +each connection. ScyllaDB echoes those options into the ``client_options`` +column of its clients table, so an operator investigating an incident can +inspect them without access to the client host: + +.. code:: sql + + SELECT address, port, client_options FROM system.clients; + +Two of the options are about the driver rather than the protocol: + +``SESSION_ID`` + A UUID identifying the ``Cluster`` object, generated when it is created. + *Every* connection the ``Cluster`` opens reports it -- the control connection + as well as the pools of each of its ``Session`` objects -- so all of a + client's connections can be told apart from those of other clients sharing + the same host. Applications can read it back from ``cluster.session_id`` and + log it, which is what allows client-side observations to be matched against + the rows above instead of correlating them by address and port. + + The option is named after the convention shared with the other ScyllaDB + drivers, where a "session" is what this driver calls a ``Cluster``. It is + unrelated to ``Session.session_id``, which identifies a ``Session`` within the + client and is never sent to the cluster. + +``DRIVER_CONFIG`` + A JSON document describing the effective configuration of the ``Cluster``. It + is the same for every one of its connections, so only the control connection + reports it, keeping the other ``STARTUP`` frames small. The document carries a + ``version`` key naming the schema it follows; further keys are added as the + driver learns to describe more of its configuration, and adding one does not + bump the version. + +.. code:: python + + from cassandra.cluster import Cluster + + cluster = Cluster() + session = cluster.connect() + + print(cluster.session_id) # matches SESSION_ID in the clients table + + for row in session.execute("SELECT client_options FROM system.clients"): + # client_options is null for rows the server has not filled in yet. + if not row.client_options: + continue + if row.client_options.get('SESSION_ID') == str(cluster.session_id): + print(row.client_options) + +Reporting the configuration is a diagnostic aid and never interferes with +connecting: a report that cannot be built, or that would not fit in a +``STARTUP`` option, is logged and left out instead of failing the handshake. + +It can be turned off with ``driver_config_reporting_enabled``: + +.. code:: python + + cluster = Cluster(driver_config_reporting_enabled=False) + +``SESSION_ID`` is unaffected by that setting: it carries no configuration, only +the identity that ties a client's connections together, and every connection +keeps reporting it. + +Alongside these, the ``application_info`` ``Cluster`` argument lets an +application add its own name, version and identifier to the same options, as +``APPLICATION_NAME``, ``APPLICATION_VERSION`` and ``CLIENT_ID``. Those are the +application's to choose; ``SESSION_ID`` and ``DRIVER_CONFIG`` are driver-owned +and cannot be overridden through it. From 76430785a2c96867cbc30951b4ac3bf88eef3134 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Thu, 20 Aug 2026 10:47:06 +0200 Subject: [PATCH 151/200] tests: use the shared clients table helper in the application info test It carried its own copy of the system/system_views fallback and the null row skip, with the same bare except that reported a timeout on system.clients as a failure of system_views.clients. --- .../standard/test_application_info.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/tests/integration/standard/test_application_info.py b/tests/integration/standard/test_application_info.py index 5d4b679fc8..03c1c9178d 100644 --- a/tests/integration/standard/test_application_info.py +++ b/tests/integration/standard/test_application_info.py @@ -15,7 +15,8 @@ import unittest from cassandra.application_info import ApplicationInfo -from tests.integration import TestCluster, use_single_node, remove_cluster, xfail_scylla_version_lt +from tests.integration import (TestCluster, get_client_options, use_single_node, remove_cluster, + xfail_scylla_version_lt) def setup_module(): @@ -77,22 +78,15 @@ def test_create_session_and_check_system_views_clients(self): found = False session = cluster.connect() - try: - rows = list(session.execute("SELECT client_options FROM system.clients")) - except Exception: - rows = list(session.execute("SELECT client_options FROM system_views.clients")) - - for row in rows: - if not row[0]: - continue + for client_options in get_client_options(session): for attribute_key, startup_key in self.attribute_to_startup_key.items(): expected_value = application_info_args.get(attribute_key) if expected_value: - if row[0].get(startup_key) != expected_value: + if client_options.get(startup_key) != expected_value: break else: # Check that it is absent - if row[0].get(startup_key, None) is not None: + if client_options.get(startup_key, None) is not None: break else: found = True From 66dcd2e0b1a13c944fe4ddb8f918c5045372d86d Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Thu, 30 Jul 2026 15:19:54 +0300 Subject: [PATCH 152/200] fix: replace __uint128_t with portable 64-bit decomposition in c_shard_info.pyx cassandra/c_shard_info.pyx computed the high 64 bits of a 64x32-bit product by casting biased_token to `__uint128_t` and shifting right by 64: cdef int shardId = (<__uint128_t>biased_token * self.shards_count) >> 64; `__uint128_t` is a GCC/Clang compiler-builtin extension type, not standard C/C++ and not part of Cython's own type system. MSVC has no 128-bit integer type at all, so it treats `__uint128_t` as an undeclared identifier and fails with cascading syntax errors (C2065/C2146/C2059) in the generated c_shard_info.c. This breaks Windows wheel builds for any change that triggers a rebuild of this extension. Replaced it with a portable multiply-high decomposition that splits the 64x32-bit multiplication into 32-bit halves, using only 64-bit arithmetic (uint64_t), matching the existing pure-Python fallback already implemented in cassandra/shard_info.py. This compiles identically on GCC, Clang, and MSVC, and is numerically identical to the previous 128-bit computation (verified against 2M+ random inputs, cross-checked against both the pure -Python fallback and the actual compiled extension). Found while investigating an unrelated Windows CI failure on PR #806; the bug is pre-existing and independent of that PR's changes, so it's fixed here as its own standalone commit rather than folded into that PR. Co-Authored-By: Claude Sonnet 5 --- cassandra/c_shard_info.pyx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/cassandra/c_shard_info.pyx b/cassandra/c_shard_info.pyx index a8affd9bba..a17ce5d65d 100644 --- a/cassandra/c_shard_info.pyx +++ b/cassandra/c_shard_info.pyx @@ -14,9 +14,6 @@ from libc.stdint cimport INT64_MIN, UINT32_MAX, uint64_t, int64_t -cdef extern from *: - ctypedef unsigned int __uint128_t - cdef class ShardingInfo(): cdef readonly int shards_count cdef readonly unicode partitioner @@ -39,5 +36,15 @@ cdef class ShardingInfo(): def shard_id_from_token(self, int64_t token_input): cdef uint64_t biased_token = token_input + (1 << 63); biased_token <<= self.sharding_ignore_msb; - cdef int shardId = (<__uint128_t>biased_token * self.shards_count) >> 64; + # Compute (biased_token * shards_count) >> 64, i.e. the high 64 bits of the + # 64x32-bit product, using only 64-bit arithmetic. This used to rely on the + # GCC/Clang-only __uint128_t extension type, which MSVC does not support at + # all (no 128-bit integer type), causing a compile error on Windows builds. + # The split below is a standard, portable multiply-high decomposition and is + # numerically identical to the previous 128-bit computation. + cdef uint64_t shards_count = self.shards_count + cdef uint64_t low_product = (biased_token & UINT32_MAX) * shards_count + cdef uint64_t carry = low_product >> 32 + cdef uint64_t mid = (biased_token >> 32) * shards_count + carry + cdef int shardId = (mid >> 32); return shardId \ No newline at end of file From 5c88e1a85ded55be64f0c61c408348a351354d60 Mon Sep 17 00:00:00 2001 From: Yaniv Kaul Date: Thu, 20 Aug 2026 09:06:13 +0300 Subject: [PATCH 153/200] fix: preserve native sharding integer arithmetic --- cassandra/c_shard_info.pyx | 31 +++++++++++++++++++------------ tests/unit/test_shard_aware.py | 20 ++++++++++++++++++++ 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/cassandra/c_shard_info.pyx b/cassandra/c_shard_info.pyx index a17ce5d65d..9ba411d7a9 100644 --- a/cassandra/c_shard_info.pyx +++ b/cassandra/c_shard_info.pyx @@ -12,7 +12,24 @@ # See the License for the specific language governing permissions and # limitations under the License. -from libc.stdint cimport INT64_MIN, UINT32_MAX, uint64_t, int64_t +from libc.stdint cimport uint64_t, int64_t + +cdef extern from *: + """ + #include + + static int cassandra_shard_id_from_token(uint64_t biased_token, uint64_t shards_count) { + #if defined(__SIZEOF_INT128__) + return (int)(((unsigned __int128)biased_token * shards_count) >> 64); + #else + uint64_t low_product = (biased_token & UINT32_MAX) * shards_count; + uint64_t carry = low_product >> 32; + uint64_t mid = (biased_token >> 32) * shards_count + carry; + return (int)(mid >> 32); + #endif + } + """ + int cassandra_shard_id_from_token(uint64_t biased_token, uint64_t shards_count) cdef class ShardingInfo(): cdef readonly int shards_count @@ -36,15 +53,5 @@ cdef class ShardingInfo(): def shard_id_from_token(self, int64_t token_input): cdef uint64_t biased_token = token_input + (1 << 63); biased_token <<= self.sharding_ignore_msb; - # Compute (biased_token * shards_count) >> 64, i.e. the high 64 bits of the - # 64x32-bit product, using only 64-bit arithmetic. This used to rely on the - # GCC/Clang-only __uint128_t extension type, which MSVC does not support at - # all (no 128-bit integer type), causing a compile error on Windows builds. - # The split below is a standard, portable multiply-high decomposition and is - # numerically identical to the previous 128-bit computation. cdef uint64_t shards_count = self.shards_count - cdef uint64_t low_product = (biased_token & UINT32_MAX) * shards_count - cdef uint64_t carry = low_product >> 32 - cdef uint64_t mid = (biased_token >> 32) * shards_count + carry - cdef int shardId = (mid >> 32); - return shardId \ No newline at end of file + return cassandra_shard_id_from_token(biased_token, shards_count) diff --git a/tests/unit/test_shard_aware.py b/tests/unit/test_shard_aware.py index 902b48a276..2d0edfe8a1 100644 --- a/tests/unit/test_shard_aware.py +++ b/tests/unit/test_shard_aware.py @@ -24,6 +24,12 @@ from cassandra.connection import ShardingInfo, DefaultEndPoint from cassandra.metadata import Murmur3Token from cassandra.protocol_features import ProtocolFeatures +from cassandra.shard_info import _ShardingInfo + +try: + from cassandra.c_shard_info import ShardingInfo as CShardingInfo +except ImportError: + CShardingInfo = None LOGGER = logging.getLogger(__name__) @@ -73,6 +79,20 @@ def mock_connection_factory(self, *args, **kwargs): class TestShardAware(unittest.TestCase): + @unittest.skipUnless(CShardingInfo, "Cython sharding extension is not available") + def test_cython_sharding_info_matches_python(self): + for shards_count in (1, 2, 4, 12, 128, 1024): + for sharding_ignore_msb in (0, 1, 12, 63): + args = (1, shards_count, "", "", sharding_ignore_msb, 0, 0) + cython_info = CShardingInfo(*args) + python_info = _ShardingInfo(*args) + for token in ( + -9223372036854775808, -1, 0, 1, + 9223372036854775807): + self.assertEqual( + cython_info.shard_id_from_token(token), + python_info.shard_id_from_token(token)) + def test_parsing_and_calculating_shard_id(self): """ Testing the parsing of the options command From 0e7bb7ae42b962d58639bcca88c4469f305d5431 Mon Sep 17 00:00:00 2001 From: Yaniv Kaul Date: Thu, 20 Aug 2026 16:25:24 +0300 Subject: [PATCH 154/200] test: cover both shard-id implementations, narrow extension import guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extension picks its multiply-high implementation at compile time: native `unsigned __int128` where the compiler has it, the portable 64-bit decomposition otherwise. Only one of the two is ever compiled on a given platform, so the portable path — the whole point of this fix — was exercised only on the Windows job, whose extension build is optional. Split the shim into `cassandra_shard_id_native` / `cassandra_shard_id_portable`, export both plus a `HAVE_INT128` flag, and cross-check them against `_ShardingInfo` in the parity test. The production path is unchanged; `shard_id_from_token` still calls `cassandra_shard_id_from_token`, which is `#define`d to whichever implementation the platform supports. Also narrow the test's import guard so only a missing extension is tolerated: a missing transitive dependency or a failed module init now propagates instead of silently downgrading the parity test to a skip. And add the missing docstring. Co-Authored-By: Claude Opus 5 (1M context) --- cassandra/c_shard_info.pyx | 40 +++++++++++++++++++++++++++++----- tests/unit/test_shard_aware.py | 32 +++++++++++++++++++++++---- 2 files changed, 63 insertions(+), 9 deletions(-) diff --git a/cassandra/c_shard_info.pyx b/cassandra/c_shard_info.pyx index 9ba411d7a9..c1a681b665 100644 --- a/cassandra/c_shard_info.pyx +++ b/cassandra/c_shard_info.pyx @@ -18,18 +18,48 @@ cdef extern from *: """ #include - static int cassandra_shard_id_from_token(uint64_t biased_token, uint64_t shards_count) { - #if defined(__SIZEOF_INT128__) - return (int)(((unsigned __int128)biased_token * shards_count) >> 64); - #else + /* Portable multiply-high: (biased_token * shards_count) >> 64 using only + 64-bit arithmetic. Always compiled so it stays testable everywhere, and + it is the only path MSVC can take (no 128-bit integer type there). */ + static int cassandra_shard_id_portable(uint64_t biased_token, uint64_t shards_count) { uint64_t low_product = (biased_token & UINT32_MAX) * shards_count; uint64_t carry = low_product >> 32; uint64_t mid = (biased_token >> 32) * shards_count + carry; return (int)(mid >> 32); - #endif } + + #if defined(__SIZEOF_INT128__) + static int cassandra_shard_id_native(uint64_t biased_token, uint64_t shards_count) { + return (int)(((unsigned __int128)biased_token * shards_count) >> 64); + } + #define CASSANDRA_HAVE_INT128 1 + #define cassandra_shard_id_from_token cassandra_shard_id_native + #else + /* unsigned __int128 is a GCC/Clang extension; MSVC has no 128-bit integer + type, so the portable decomposition is the only implementation. */ + static int cassandra_shard_id_native(uint64_t biased_token, uint64_t shards_count) { + return cassandra_shard_id_portable(biased_token, shards_count); + } + #define CASSANDRA_HAVE_INT128 0 + #define cassandra_shard_id_from_token cassandra_shard_id_portable + #endif """ int cassandra_shard_id_from_token(uint64_t biased_token, uint64_t shards_count) + int cassandra_shard_id_native(uint64_t biased_token, uint64_t shards_count) + int cassandra_shard_id_portable(uint64_t biased_token, uint64_t shards_count) + int CASSANDRA_HAVE_INT128 + +HAVE_INT128 = bool(CASSANDRA_HAVE_INT128) + + +def _shard_id_from_token_impl(int64_t token_input, int shards_count, int sharding_ignore_msb, + bint portable): + """Test hook: run one shard-id computation through a chosen implementation.""" + cdef uint64_t biased_token = token_input + (1 << 63) + biased_token <<= sharding_ignore_msb + if portable: + return cassandra_shard_id_portable(biased_token, shards_count) + return cassandra_shard_id_native(biased_token, shards_count) cdef class ShardingInfo(): cdef readonly int shards_count diff --git a/tests/unit/test_shard_aware.py b/tests/unit/test_shard_aware.py index 2d0edfe8a1..af27a84011 100644 --- a/tests/unit/test_shard_aware.py +++ b/tests/unit/test_shard_aware.py @@ -27,8 +27,14 @@ from cassandra.shard_info import _ShardingInfo try: + import cassandra.c_shard_info as c_shard_info from cassandra.c_shard_info import ShardingInfo as CShardingInfo -except ImportError: +except ModuleNotFoundError as exc: + # Only tolerate the extension simply not being built. A missing transitive + # dependency or a failed module init must not silently skip the parity test. + if exc.name != "cassandra.c_shard_info": + raise + c_shard_info = None CShardingInfo = None LOGGER = logging.getLogger(__name__) @@ -81,6 +87,15 @@ def mock_connection_factory(self, *args, **kwargs): class TestShardAware(unittest.TestCase): @unittest.skipUnless(CShardingInfo, "Cython sharding extension is not available") def test_cython_sharding_info_matches_python(self): + """ + Testing that the compiled extension computes the same shard id as the + pure-Python fallback, for both of its multiply-high implementations. + + The extension uses native 128-bit arithmetic where the compiler has it + and a portable 64-bit decomposition otherwise (MSVC). Only one of the + two is wired up on any given platform, so both are exercised directly + here to keep the MSVC path covered on every platform. + """ for shards_count in (1, 2, 4, 12, 128, 1024): for sharding_ignore_msb in (0, 1, 12, 63): args = (1, shards_count, "", "", sharding_ignore_msb, 0, 0) @@ -89,9 +104,18 @@ def test_cython_sharding_info_matches_python(self): for token in ( -9223372036854775808, -1, 0, 1, 9223372036854775807): - self.assertEqual( - cython_info.shard_id_from_token(token), - python_info.shard_id_from_token(token)) + with self.subTest(shards_count=shards_count, + sharding_ignore_msb=sharding_ignore_msb, + token=token): + expected = python_info.shard_id_from_token(token) + self.assertEqual( + cython_info.shard_id_from_token(token), expected) + for portable in (True, False): + self.assertEqual( + c_shard_info._shard_id_from_token_impl( + token, shards_count, sharding_ignore_msb, + portable), + expected) def test_parsing_and_calculating_shard_id(self): """ From 6e35483bd30d2b440cd1e24ce7f7e9d1fa024a7b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:57:01 +0000 Subject: [PATCH 155/200] Update dependency lz4 to v1.10.0 --- conanfile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conanfile.py b/conanfile.py index 97584b58a1..d35e1f50d1 100644 --- a/conanfile.py +++ b/conanfile.py @@ -50,7 +50,7 @@ class python_driverConan(ConanFile): win_bash = False settings = "os", "compiler", "build_type", "arch" - requires = "libev/4.33", "lz4/1.9.4" + requires = "libev/4.33", "lz4/1.10.0" def layout(self): basic_layout(self) From a4e9f7b7f4156897328af3956add309a3e3ec0a1 Mon Sep 17 00:00:00 2001 From: Patrycja Ziemkiewicz Date: Mon, 10 Aug 2026 11:25:10 +0200 Subject: [PATCH 156/200] CI: drop the unused OpenSSL installs from the wheel builds Neither install was ever used by anything we build. Windows: nothing in the job ever used it. Nothing there compiles against OpenSSL, so no headers were needed. And everything that does TLS at runtime carries its own copy - Python brings its own libssl/libcrypto, and the cryptography package has OpenSSL built in - so nothing ever looks for a system-wide install. The step just put a copy on the runner that sat unused. Linux: the same, in two halves. `openssl` was already in the manylinux image, so asking for it changed nothing. `openssl-devel` (the headers) did install something new, but nothing ever compiled against it - every test dependency arrives as a ready-made wheel, and the only one that needs OpenSSL, cryptography, has it built into that wheel. --- .github/workflows/lib-build.yml | 5 ----- pyproject.toml | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/lib-build.yml b/.github/workflows/lib-build.yml index 17505a5fd6..529e24b426 100644 --- a/.github/workflows/lib-build.yml +++ b/.github/workflows/lib-build.yml @@ -104,11 +104,6 @@ jobs: run: | uv tool install 'cibuildwheel==3.2.1' - - name: Install OpenSSL for Windows - if: runner.os == 'Windows' - run: | - choco install openssl.light --no-progress -y - - name: Install Conan if: runner.os == 'Windows' uses: turtlebrowser/get-conan@c171f295f3f507360ee018736a6608731aa2109d # v1.2 diff --git a/pyproject.toml b/pyproject.toml index 0d7a042d0e..f44f99c971 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -172,7 +172,7 @@ manylinux-pypy_aarch64-image = "manylinux_2_28" enable = ["pypy"] [tool.cibuildwheel.linux] -before-build = "rm -rf ~/.pyxbld && rpm --import https://repo.almalinux.org/almalinux/RPM-GPG-KEY-AlmaLinux && yum install -y libffi-devel libev libev-devel openssl openssl-devel lz4-devel" +before-build = "rm -rf ~/.pyxbld && rpm --import https://repo.almalinux.org/almalinux/RPM-GPG-KEY-AlmaLinux && yum install -y libffi-devel libev libev-devel lz4-devel" # Install the optional lz4 compression dependency so the lz4 segment tests run # (and fail loudly under CASS_DRIVER_NO_SKIP) instead of skipping silently. test-extras = ["compress-lz4"] From 10e8b6f2f782bc1a83eda10e744422321fcc16d0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:36:11 +0000 Subject: [PATCH 157/200] Update actions/checkout action to v6.0.3 --- .github/workflows/coverage.yml | 2 +- .github/workflows/docs-pages.yml | 2 +- .github/workflows/docs-pr.yml | 2 +- .github/workflows/integration-tests.yml | 2 +- .github/workflows/lib-build.yml | 6 +++--- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 8f95376d02..ed28084d0c 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -46,7 +46,7 @@ jobs: CASS_DRIVER_NO_CYTHON: "1" steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up JDK 8 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml index a413e3317e..6c4828635b 100644 --- a/.github/workflows/docs-pages.yml +++ b/.github/workflows/docs-pages.yml @@ -24,7 +24,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ github.event.repository.default_branch }} persist-credentials: false diff --git a/.github/workflows/docs-pr.yml b/.github/workflows/docs-pr.yml index 1881c227ed..b829b4820c 100644 --- a/.github/workflows/docs-pr.yml +++ b/.github/workflows/docs-pr.yml @@ -31,7 +31,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false fetch-depth: 0 diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index acebb1d617..25ef4ff100 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -56,7 +56,7 @@ jobs: event_loop_manager: "asyncore" steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up JDK ${{ matrix.java-version }} uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 diff --git a/.github/workflows/lib-build.yml b/.github/workflows/lib-build.yml index 529e24b426..f81dc4bcb1 100644 --- a/.github/workflows/lib-build.yml +++ b/.github/workflows/lib-build.yml @@ -77,11 +77,11 @@ jobs: include: ${{ fromJson(needs.prepare-matrix.outputs.matrix) }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Checkout tag ${{ inputs.target_tag }} if: inputs.target_tag != '' - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ inputs.target_tag }} @@ -151,7 +151,7 @@ jobs: name: Build source distribution runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Install uv uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 From 1d8e44db5ca1f7e94f938d87b22fa20bd72819bc Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Tue, 25 Aug 2026 14:12:53 +0200 Subject: [PATCH 158/200] DRIVER-379: Record whether the local datacenter was configured The configuration report has to tell a datacenter the user chose from one the driver inferred, and DCAwareRoundRobinPolicy cannot: on_up() assigns the inferred datacenter to the same local_dc attribute the constructor set, so from the first host coming up the two are indistinguishable. Capture it at construction instead, where an empty local_dc counts as inferred -- which is what makes on_up() infer. local_dc becomes read-only for the same reason. An assignment afterwards would be indistinguishable from that inference all over again, so there would be nothing left to capture: the constructor sets it, and on_up() fills it in when the constructor was given none. Code that assigned it should pass local_dc to the constructor instead. RackAwareRoundRobinPolicy needs no such flag: both values are mandatory constructor arguments and are never reassigned. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.rst | 6 +++++ cassandra/policies.py | 25 ++++++++++++++++--- tests/unit/test_policies.py | 48 +++++++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 068be2e048..8cab8591fd 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -20,6 +20,12 @@ Features Others ------ +* ``DCAwareRoundRobinPolicy.local_dc`` is now read-only. It is set by the constructor, + and filled in by the policy itself when the constructor was given none, from the first + host to come up. Assigning it afterwards was indistinguishable from that inference, + and the two mean different things: a datacenter the application chose against one the + driver guessed. Code that assigned it should pass ``local_dc`` to the constructor + instead. * The ``STARTUP`` options that describe the driver itself are no longer the application's to set. An ``ApplicationInfoBase.add_startup_options`` that sets ``DRIVER_NAME``, ``DRIVER_VERSION``, ``SESSION_ID`` or ``DRIVER_CONFIG`` now has that diff --git a/cassandra/policies.py b/cassandra/policies.py index f1bfefb41d..ee24b4b5a8 100644 --- a/cassandra/policies.py +++ b/cassandra/policies.py @@ -222,9 +222,24 @@ class DCAwareRoundRobinPolicy(LoadBalancingPolicy): datacenters as a last resort. """ - local_dc = None + _local_dc = None + _local_dc_explicit = False used_hosts_per_remote_dc = 0 + @property + def local_dc(self): + """ + The datacenter treated as local, whether it was configured through the + constructor or inferred from the first host to come up. + + Read-only. It is the constructor's to set, and on_up()'s to fill in when + the constructor was given nothing: an assignment afterwards would be + indistinguishable from that inference, and telling the two apart is the + difference between a datacenter an application chose and one the driver + guessed. + """ + return self._local_dc + def __init__(self, local_dc='', used_hosts_per_remote_dc=0): """ The `local_dc` parameter should be the name of the datacenter @@ -241,7 +256,11 @@ def __init__(self, local_dc='', used_hosts_per_remote_dc=0): rest will be considered :attr:`~.HostDistance.IGNORED`. By default, all remote hosts are ignored. """ - self.local_dc = local_dc + self._local_dc = local_dc + # Whether the datacenter was chosen here or is left to on_up() to infer. + # An empty local_dc is the default rather than a choice, which is also + # what makes on_up() infer one. + self._local_dc_explicit = bool(local_dc) self.used_hosts_per_remote_dc = used_hosts_per_remote_dc self._dc_live_hosts = {} self._position = 0 @@ -295,7 +314,7 @@ def on_up(self, host): # not worrying about threads because this will happen during # control connection startup/refresh if not self.local_dc and host.datacenter: - self.local_dc = host.datacenter + self._local_dc = host.datacenter log.info("Using datacenter '%s' for DCAwareRoundRobinPolicy (via host '%s'); " "if incorrect, please specify a local_dc to the constructor, " "or limit contact points to local cluster nodes" % diff --git a/tests/unit/test_policies.py b/tests/unit/test_policies.py index 35c1a96f87..2fc31a31df 100644 --- a/tests/unit/test_policies.py +++ b/tests/unit/test_policies.py @@ -570,6 +570,54 @@ def test_default_dc(self): policy.on_add(host_remote) assert policy.local_dc + def test_local_dc_explicit(self): + """ + The configured/inferred distinction has to survive inference, since + that is the only point at which the two look alike. + """ + assert DCAwareRoundRobinPolicy('local')._local_dc_explicit + assert not DCAwareRoundRobinPolicy()._local_dc_explicit + # An empty datacenter is what the default is, so it is not a choice. + assert not DCAwareRoundRobinPolicy('')._local_dc_explicit + + host_local = Host(DefaultEndPoint(1), SimpleConvictionPolicy, 'local', host_id=uuid.uuid4()) + cluster = Mock(endpoints_resolved=[DefaultEndPoint(1)]) + + policy = DCAwareRoundRobinPolicy() + policy.populate(cluster, [host_local]) + policy.on_add(host_local) + assert policy.local_dc == 'local' + assert not policy._local_dc_explicit + + def test_a_subclass_that_declares_a_datacenter_keeps_it(self): + """ + local_dc is a read-only property, and a subclass is still free to + shadow it with a plain class attribute. Doing so declares a datacenter, + so inference leaves it alone -- on_up only fills in one that is unset. + """ + class Pinned(DCAwareRoundRobinPolicy): + local_dc = 'configured' + + host_local = Host(DefaultEndPoint(1), SimpleConvictionPolicy, 'local', host_id=uuid.uuid4()) + + policy = Pinned() + policy.on_up(host_local) + + assert policy.local_dc == 'configured' + + def test_the_datacenter_is_read_only(self): + """ + Set through the constructor and nowhere else. An assignment afterwards + would be indistinguishable from on_up's inference, and the two mean + different things to anything reading the policy. + """ + policy = DCAwareRoundRobinPolicy('dc1') + + with pytest.raises(AttributeError): + policy.local_dc = 'dc2' + + assert policy.local_dc == 'dc1' + class TokenAwarePolicyTest(unittest.TestCase): def test_wrap_round_robin(self): From dfc65141b47cf51827d8a704391fa4f249ef2949 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Thu, 27 Aug 2026 12:20:23 +0200 Subject: [PATCH 159/200] DRIVER-379: Derive the connection limits from the limit in force Connection.max_request_id and Connection.orphaned_threshold are both derived from max_in_flight in the class body -- which runs once. A subclass that lowers max_in_flight inherits values derived from this class's, not from its own. For orphaned_threshold that leaves a max_in_flight of 256 paired with a threshold of 24576. A connection can hold no more orphaned stream ids than it has request ids, so the count never reaches the threshold and orphan-based replacement never happens for such a subclass at all -- the safety mechanism is there and silently dead. max_in_flight is documented as the knob for lower-level integrations that want an upper bound without reimplementing the connection, so it is meant to be lowered. Both are now derived rather than fixed in the class body. A connection derives them in __init__ from the limit in force when it is built, so they follow max_in_flight however it was tuned -- a subclass setting it in a class body, an assignment on the class, or a patch in a test. The last two are how the integration tests covering the in-flight bound set it, and a value derived once would leave the pool's `in_flight < max_request_id` gate at the untuned bound, never tripping. The two staticmethods are for the configuration report, which has to describe both before any connection exists: it asks with the class's current limit rather than restating the arithmetic. Three places did restate it -- the report and two of its tests -- and halving the expression in __init__ left every one of them agreeing with each other and disagreeing with the driver, with the whole suite passing. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.rst | 14 +++++++ cassandra/connection.py | 48 ++++++++++++++++++++-- tests/unit/test_connection.py | 77 +++++++++++++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8cab8591fd..c209ccaa67 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -26,6 +26,20 @@ Others and the two mean different things: a datacenter the application chose against one the driver guessed. Code that assigned it should pass ``local_dc`` to the constructor instead. +* ``Connection.max_request_id`` and ``Connection.orphaned_threshold`` now follow the + ``max_in_flight`` actually in force. Both were computed in the class body, which runs + once, so a subclass that set its own ``max_in_flight`` inherited values derived from the + base class -- leaving, for example, a ``max_in_flight`` of 256 with a threshold of + 24576, which a connection holding at most 256 orphaned stream ids can never reach, so + orphan-based connection replacement never happened for such a subclass. Each connection + now derives both in ``__init__`` from the limit in force when it is built, which + overrides a value a subclass sets in its class body. ``orphaned_threshold`` is also + capped at three quarters of the CQL stream id range, as ``max_request_id`` already was: + a ``max_in_flight`` raised past that range left the threshold above the number of stream + ids a connection can hold at all, which is the same bug in the other direction. The two + new static methods ``Connection.max_request_id_for()`` and + ``Connection.orphaned_threshold_for()`` expose the derivation, so that both limits can + be read for a given ``max_in_flight`` before any connection exists. * The ``STARTUP`` options that describe the driver itself are no longer the application's to set. An ``ApplicationInfoBase.add_startup_options`` that sets ``DRIVER_NAME``, ``DRIVER_VERSION``, ``SESSION_ID`` or ``DRIVER_CONFIG`` now has that diff --git a/cassandra/connection.py b/cassandra/connection.py index af95891a3b..9eb0d762f5 100644 --- a/cassandra/connection.py +++ b/cassandra/connection.py @@ -844,10 +844,50 @@ class Connection(object): # and the connection will be replaced orphaned_threshold_reached = False + # The CQL stream id space, which is all the protocol can address however high + # max_in_flight is set. Both limits below are capped to it. + _MAX_STREAM_IDS = 2 ** 15 + # If the number of orphaned streams reaches this threshold, this connection # will become marked and will be replaced with a new connection by the - # owning pool (currently, only HostConnection supports this) - orphaned_threshold = 3 * max_in_flight // 4 + # owning pool (currently, only HostConnection supports this). The default + # for this class's max_in_flight; a connection derives its own in __init__. + orphaned_threshold = 3 * min(max_in_flight, _MAX_STREAM_IDS) // 4 + + @staticmethod + def max_request_id_for(max_in_flight): + """ + The highest request id a connection with this limit will hand out. + + Request ids run from zero to this inclusive, and borrow_connection + admits a request only while in_flight is below it. Capped at the CQL + stream id range, which is all the protocol can address however high + max_in_flight is set. + """ + return min(max_in_flight, Connection._MAX_STREAM_IDS) - 1 + + @staticmethod + def orphaned_threshold_for(max_in_flight): + """ + The orphaned stream count at which a connection with this limit is + marked for replacement. + + Three quarters of the stream ids a connection can actually hold, which + is max_in_flight capped the way :meth:`max_request_id_for` caps it. + Taken off that capped pool rather than off max_in_flight itself: a + connection holds at most max_request_id + 1 ids, so a threshold above + that is one `len(orphaned_request_ids) >= orphaned_threshold` never + reaches, leaving orphan-based replacement dead for a max_in_flight + raised past the stream id range. + """ + return 3 * min(max_in_flight, Connection._MAX_STREAM_IDS) // 4 + + # Both limits are derived, and both are asked for rather than stored on the + # class, because max_in_flight is tuned at runtime -- assigned on the class, + # or patched in a test -- and a value derived once does not follow it. A + # connection derives both in __init__ from the limit in force when it is + # built, and the configuration report, which has to describe them before any + # connection exists, asks with the class's current limit. is_defunct = False is_closed = False @@ -944,7 +984,9 @@ def __init__(self, host='127.0.0.1', port=9042, authenticator=None, if not self.ssl_context and self.ssl_options: self.ssl_context = self._build_ssl_context_from_options() - self.max_request_id = min(self.max_in_flight - 1, (2 ** 15) - 1) + self.max_request_id = self.max_request_id_for(self.max_in_flight) + self.orphaned_threshold = self.orphaned_threshold_for(self.max_in_flight) + # Don't fill the deque with 2**15 items right away. Start with some and add # more if needed. initial_size = min(300, self.max_in_flight) diff --git a/tests/unit/test_connection.py b/tests/unit/test_connection.py index 5962db1189..adbe0833cc 100644 --- a/tests/unit/test_connection.py +++ b/tests/unit/test_connection.py @@ -414,6 +414,83 @@ def test_wait_for_responses_shutdown_includes_last_error(self): assert "Bad file descriptor" in error_message +class DerivedConnectionLimitsTest(unittest.TestCase): + """ + max_request_id and orphaned_threshold are derived from max_in_flight, which + is tuned at runtime -- assigned on the class, or patched in a test -- so a + value derived once would not follow it. + """ + + def test_the_derivations(self): + assert Connection.max_request_id_for(32768) == 32767 + assert Connection.max_request_id_for(256) == 255 + # Capped at the stream id range the protocol can address. + assert Connection.max_request_id_for(2 ** 20) == (2 ** 15) - 1 + assert Connection.orphaned_threshold_for(32768) == 24576 + assert Connection.orphaned_threshold_for(256) == 192 + # Capped the same way, and for the same reason. + assert Connection.orphaned_threshold_for(2 ** 20) == 24576 + + def test_the_threshold_stays_within_the_ids_a_connection_holds(self): + """ + A connection hands out request ids zero through max_request_id, so it + can hold no more orphans than one more than that. A threshold above the + pool is one `len(orphaned_request_ids) >= orphaned_threshold` never + reaches, which leaves orphan-based connection replacement dead -- what a + max_in_flight raised past the stream id range used to do. + """ + for max_in_flight in (2, 256, 32768, 2 ** 16, 2 ** 20): + pool = Connection.max_request_id_for(max_in_flight) + 1 + + assert Connection.orphaned_threshold_for(max_in_flight) <= pool, max_in_flight + + def test_a_connection_derives_both_from_the_limit_in_force(self): + """ + The regression this guards: __init__ derives them, so a limit tuned + before the connection is built is the one it carries. Deriving them once + on the class instead left every connection at the original bound. + """ + with patch('cassandra.connection.Connection.max_in_flight', 50): + connection = Connection(DefaultEndPoint('1.2.3.4')) + + assert connection.max_request_id == 49 + assert connection.orphaned_threshold == 37 + + def test_the_limits_follow_a_runtime_assignment(self): + """ + Connection.max_in_flight = N on the class, which the integration tests + covering the in-flight bound do. A limit derived once would leave the + pool's `in_flight < max_request_id` gate at the old value and never + trip. + """ + original = Connection.max_in_flight + try: + Connection.max_in_flight = 50 + + assert Connection.max_request_id_for(Connection.max_in_flight) == 49 + assert Connection.orphaned_threshold_for(Connection.max_in_flight) == 37 + finally: + Connection.max_in_flight = original + + def test_the_limits_follow_a_patched_limit(self): + """ + The other shape the integration tests use. + """ + with patch('cassandra.connection.Connection.max_in_flight', 2): + assert Connection.max_request_id_for(Connection.max_in_flight) == 1 + assert Connection.orphaned_threshold_for(Connection.max_in_flight) == 1 + + def test_a_subclass_that_lowers_the_limit_derives_its_own(self): + class Small(Connection): + max_in_flight = 256 + + assert Small.max_request_id_for(Small.max_in_flight) == 255 + assert Small.orphaned_threshold_for(Small.max_in_flight) == 192 + # The point of deriving: a connection can hold no more orphans than it + # has request ids, so the base class's 24576 could never be reached. + assert Small.orphaned_threshold_for(Small.max_in_flight) < Small.max_in_flight + + class StartupOptionsTest(unittest.TestCase): """ Covers the options the driver puts in the STARTUP frame, by driving a From 454de5723c8494dc0e8d18297d6e8d32afd7d2df Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Tue, 25 Aug 2026 14:12:53 +0200 Subject: [PATCH 160/200] DRIVER-379: Vendor the report schema and validate against it The DRIVER_CONFIG report is a cross-driver contract: an operator reading system.clients.client_options relies on the same document whichever driver wrote the row. That contract is a JSON Schema maintained upstream, vendored here byte for byte -- and pinned as such by a test -- so drift from the shared copy shows up as a diff rather than as a divergence nobody notices. Validating is worth the dependency because every group in the schema is additionalProperties: false, so a key this driver invents or misspells fails hard instead of being silently dropped by a consumer. These tests cover the harness and the contract, not the reporter, whose report is still only {"version":1}: connection, control-plane and query are all required, so it becomes conformant once the last of those groups lands. Landing the schema first means every commit in between is checked against it. Co-Authored-By: Claude Opus 5 (1M context) --- pyproject.toml | 1 + tests/driver_config_schema.py | 89 ++ tests/resources/driver-config-schema-v1.json | 1026 ++++++++++++++++++ tests/unit/test_driver_config_schema.py | 191 ++++ uv.lock | 515 +++++++++ 5 files changed, 1822 insertions(+) create mode 100644 tests/driver_config_schema.py create mode 100644 tests/resources/driver-config-schema-v1.json create mode 100644 tests/unit/test_driver_config_schema.py diff --git a/pyproject.toml b/pyproject.toml index f44f99c971..2b740f9292 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,7 @@ dev = [ "numpy", "objgraph", "coverage[toml]>=7.6", + "jsonschema>=4.18", "ccm @ git+https://git@github.com/scylladb/scylla-ccm.git@master", ] diff --git a/tests/driver_config_schema.py b/tests/driver_config_schema.py new file mode 100644 index 0000000000..8121e3ff94 --- /dev/null +++ b/tests/driver_config_schema.py @@ -0,0 +1,89 @@ +# Copyright 2026 ScyllaDB, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Validation of the ``DRIVER_CONFIG`` report against the schema shared by the +ScyllaDB drivers. + +The schema is the cross-driver contract: it is what an operator reading +``system.clients.client_options`` can rely on, whichever driver wrote the row. +Every group it defines is ``additionalProperties: false``, so a key this driver +invents, or misspells, is a validation failure rather than something a consumer +silently ignores. +""" + +import json +import os + +import jsonschema + +SCHEMA_PATH = os.path.join(os.path.dirname(__file__), 'resources', + 'driver-config-schema-v1.json') +""" +Vendored copy of the normative schema, byte for byte as it appears upstream, +which is where it is maintained: + + https://github.com/scylladb/gocql/blob/master/docs/driver-config-schema.json + +Vendored rather than reformatted, so that a drift from the shared contract shows +up as a diff in this file instead of as a divergence nobody notices. +""" + + +def load_schema(): + """ + Returns the parsed schema. Not cached: the callers are tests, and a mutable + document shared between them is a worse trade than re-reading a 30 KiB file. + """ + with open(SCHEMA_PATH, encoding='utf8') as f: + return json.load(f) + + +def _validator(): + """ + A validator built once for the whole test run. + + Built here rather than through :func:`jsonschema.validate`, which reparses + the file and recompiles the validator on every call -- and this is called + from every unit case that produces a report as well as from every + integration one. The validator keeps the schema to itself and never hands it + back, so sharing one costs none of the isolation :func:`load_schema` exists + to give a caller that wants the document. + + The dialect comes from the schema's own ``$schema``, so a future revision of + the shared contract is validated as whatever it declares itself to be. + """ + schema = load_schema() + cls = jsonschema.validators.validator_for(schema) + cls.check_schema(schema) + return cls(schema) + + +_VALIDATOR = _validator() + + +def validate_report(report): + """ + Validates a configuration report against the schema, raising + :exc:`jsonschema.ValidationError` if it does not conform. + + `report` is either the JSON text of the ``DRIVER_CONFIG`` option, as it goes + on the wire and comes back out of the clients table, or an already parsed + document. Returns the parsed document, so a test can go on to assert + specific values against the thing that was validated. + """ + if isinstance(report, (str, bytes)): + report = json.loads(report) + + _VALIDATOR.validate(report) + return report diff --git a/tests/resources/driver-config-schema-v1.json b/tests/resources/driver-config-schema-v1.json new file mode 100644 index 0000000000..64f8ad6ce8 --- /dev/null +++ b/tests/resources/driver-config-schema-v1.json @@ -0,0 +1,1026 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://scylladb.com/schemas/driver-client-options/v1.json", + "title": "ScyllaDB driver DRIVER_CONFIG configuration", + "description": "Schema for the JSON value sent under the STARTUP option key DRIVER_CONFIG, describing the effective client configuration. The top-level object must include `version` and the required configuration groups listed by this schema. Unknown top-level keys are rejected. Built-in groups reject unknown keys and require the keys listed in each group; custom policy objects may include additional implementation-specific public attributes where explicitly allowed.", + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "connection", + "control-plane", + "query" + ], + "properties": { + "version": { + "description": "Major schema version. Adding keys is backward-compatible and does not bump this; only changing/removing the meaning of an existing key does.", + "type": "integer", + "const": 1 + }, + "connection": { + "$ref": "#/$defs/connection" + }, + "control-plane": { + "$ref": "#/$defs/control-plane" + }, + "query": { + "$ref": "#/$defs/query" + } + }, + "$defs": { + "positiveInteger": { + "type": "integer", + "minimum": 1 + }, + "nonNegativeInteger": { + "type": "integer", + "minimum": 0 + }, + "nonEmptyString": { + "type": "string", + "minLength": 1 + }, + "retryPolicyBackoff": { + "description": "Delay inserted between retry attempts of a retry policy. Discriminated union: when present, `type` selects the backoff algorithm and each algorithm carries only its own parameters. Absent when there is no delay between attempts.", + "oneOf": [ + { + "type": "object", + "description": "Exponential backoff: the delay starts at base-ms and doubles after each attempt (capped at max-ms), with a small random jitter to de-synchronize concurrent retries. When max-ms is present, it MUST be greater than or equal to base-ms; this cross-property invariant must be checked by the producer or consumer because JSON Schema Draft 2020-12 cannot compare sibling numeric values.", + "additionalProperties": false, + "required": [ + "type", + "base-ms" + ], + "properties": { + "type": { + "const": "exponential", + "description": "Exponential backoff algorithm." + }, + "base-ms": { + "$ref": "#/$defs/positiveInteger", + "description": "Initial delay between retries in milliseconds; the starting delay that doubles each attempt." + }, + "max-ms": { + "$ref": "#/$defs/positiveInteger", + "description": "Maximum delay between retries in milliseconds; the exponentially growing delay is capped here. MUST be greater than or equal to base-ms. Absent when no maximum delay is configured." + } + } + }, + { + "type": "object", + "description": "Constant backoff: wait a fixed, strictly positive delay between every retry attempt.", + "additionalProperties": false, + "required": [ + "type", + "delay-ms" + ], + "properties": { + "type": { + "const": "constant", + "description": "Constant (fixed-delay) backoff algorithm." + }, + "delay-ms": { + "$ref": "#/$defs/positiveInteger", + "description": "Fixed delay between retries in milliseconds. Must be greater than 0; omit backoff when no delay is configured." + } + } + } + ] + }, + "requests": { + "type": "object", + "description": "Per-connection CQL request and protocol stream capacity. `orphaned.max` is expected to be lower than `in-flight.max`.", + "additionalProperties": false, + "required": [ + "in-flight" + ], + "properties": { + "in-flight": { + "type": "object", + "description": "Requests currently awaiting a response on the connection.", + "additionalProperties": false, + "required": [ + "max" + ], + "properties": { + "max": { + "$ref": "#/$defs/positiveInteger", + "description": "Maximum number of concurrent in-flight requests allowed on one connection." + } + } + }, + "orphaned": { + "type": "object", + "description": "Requests that the client stopped waiting for but whose stream identifiers cannot yet be safely reused.", + "additionalProperties": false, + "required": [ + "max" + ], + "properties": { + "max": { + "$ref": "#/$defs/nonNegativeInteger", + "description": "Maximum number of orphaned requests allowed on one connection before the driver closes and replaces it. Absent only when this bound is unknown, for example when the client never replaces a connection over accumulated orphans and so has no limit to report." + } + } + } + } + }, + "connection-pool": { + "description": "Connection pooling configuration.", + "type": "object", + "required": [ + "shard-aware" + ], + "additionalProperties": false, + "properties": { + "shard-aware": { + "type": "object", + "required": [ + "enabled" + ], + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether the client is configured to use ScyllaDB's dedicated shard-aware port (default 19042, TLS 19043) to reach a chosen shard in a single connect, versus the fallback of opening connections on the normal port and reading the server-assigned shard. Reports configuration intent; at runtime the port must also be advertised by the server and reachable, otherwise the client falls back transparently." + } + } + } + } + }, + "connection": { + "description": "Connection-level settings: socket read/write/connect timeouts plus the CQL-level idle heartbeat. Durations are in milliseconds. Optional duration fields are absent when unset or not applicable.", + "type": "object", + "required": [ + "connect", + "requests", + "pool", + "socket", + "reconnection" + ], + "additionalProperties": false, + "properties": { + "requests": { + "$ref": "#/$defs/requests" + }, + "node-preference": { + "$ref": "#/$defs/node-location-preference", + "description": "Defines part of the cluster driver holds connections to." + }, + "connect": { + "type": "object", + "description": "Settings for establishing a TCP/CQL connection to a node.", + "additionalProperties": false, + "properties": { + "timeout-ms": { + "$ref": "#/$defs/positiveInteger", + "description": "Timeout for establishing a TCP/CQL connection to a node." + } + } + }, + "read": { + "type": "object", + "description": "Settings for reading from a connection.", + "additionalProperties": false, + "properties": { + "timeout-ms": { + "$ref": "#/$defs/positiveInteger", + "description": "Read operation timeout." + } + } + }, + "write": { + "type": "object", + "description": "Settings for writing to a connection. Direction-specific options such as write coalescing are expected to be added here in a future schema version.", + "additionalProperties": false, + "properties": { + "coalescing": { + "type": "object", + "description": "Settings for write coalescing. It is a placeholder for v2", + "additionalProperties": false, + "properties": {} + }, + "timeout-ms": { + "$ref": "#/$defs/positiveInteger", + "description": "Write operation timeout." + } + } + }, + "heartbeat": { + "type": "object", + "description": "Reserved for CQL-level idle heartbeat settings. Optional and intentionally empty in this schema version. It is a placeholder for v2", + "additionalProperties": false, + "properties": {} + }, + "pool": { + "$ref": "#/$defs/connection-pool", + "description": "A connection pooling configuration." + }, + "socket": { + "$ref": "#/$defs/socket" + }, + "reconnection": { + "description": "Connection reconnection configuration.", + "type": "object", + "required": [ + "policy" + ], + "additionalProperties": false, + "properties": { + "policy": { + "$ref": "#/$defs/reconnection-policy" + } + } + }, + "tls": { + "$ref": "#/$defs/tls" + } + } + }, + "control-plane": { + "description": "Control-plane timeout settings for internal/system queries run over the control connection and for schema agreement. Each value is in milliseconds. Optional values are absent when unset or not applicable.", + "type": "object", + "required": [ + "queries", + "schema" + ], + "additionalProperties": false, + "properties": { + "queries": { + "type": "object", + "description": "Control-plane query settings.", + "additionalProperties": false, + "required": [ + "system" + ], + "properties": { + "system": { + "type": "object", + "description": "Settings for internal/system queries run over the control connection.", + "additionalProperties": false, + "required": [ + "timeout" + ], + "properties": { + "timeout": { + "type": "object", + "description": "Timeouts applied to internal/system queries. Each value is in milliseconds. Optional values are absent when unset or not applicable.", + "additionalProperties": false, + "properties": { + "client-side-ms": { + "$ref": "#/$defs/positiveInteger", + "description": "A client-side timeout for internal queries." + }, + "server-side-ms": { + "$ref": "#/$defs/positiveInteger", + "description": "A server-side timeout for internal queries." + } + } + } + } + } + } + }, + "schema": { + "type": "object", + "description": "Control-plane schema settings.", + "additionalProperties": false, + "required": [ + "agreement" + ], + "properties": { + "agreement": { + "type": "object", + "description": "Settings for schema agreement across nodes.", + "additionalProperties": false, + "required": [ + "timeout-ms" + ], + "properties": { + "timeout-ms": { + "$ref": "#/$defs/nonNegativeInteger", + "description": "Maximum time to wait for schema agreement across nodes. Always a concrete value; 0 means do not wait for agreement." + } + } + } + } + } + } + }, + "socket": { + "description": "Low-level TCP socket options applied to client connections. Boolean options (tcp-no-delay, keep-alive, reuse-address) report the effective on/off state: when no explicit value is configured, the OS/platform default is reported. Buffer sizes are in bytes and linger is in seconds; these fields are absent when unset (kernel auto-tuned buffer / linger disabled).", + "type": "object", + "required": [ + "tcp-no-delay", + "keep-alive", + "reuse-address" + ], + "additionalProperties": false, + "properties": { + "tcp-no-delay": { + "type": "boolean", + "description": "TCP_NODELAY: disable Nagle's algorithm. Reports the effective value; when no explicit value is configured, the OS/platform default is reported." + }, + "keep-alive": { + "type": "boolean", + "description": "SO_KEEPALIVE: OS-level TCP keep-alive probes on idle connections. Reports the effective on/off state; when no explicit value is configured, the OS/platform default is reported." + }, + "reuse-address": { + "type": "boolean", + "description": "SO_REUSEADDR: allow reuse of a local address. Reports the effective on/off state; when no explicit value is configured, the OS/platform default is reported." + }, + "linger": { + "type": "object", + "required": [ + "interval-s" + ], + "additionalProperties": false, + "properties": { + "interval-s": { + "$ref": "#/$defs/nonNegativeInteger", + "description": "SO_LINGER lingering-close interval in seconds." + } + } + }, + "receive-buffer": { + "type": "object", + "required": [ + "size-bytes" + ], + "additionalProperties": false, + "properties": { + "size-bytes": { + "$ref": "#/$defs/positiveInteger", + "description": "SO_RCVBUF socket receive buffer size hint in bytes." + } + } + }, + "send-buffer": { + "type": "object", + "required": [ + "size-bytes" + ], + "additionalProperties": false, + "properties": { + "size-bytes": { + "$ref": "#/$defs/positiveInteger", + "description": "SO_SNDBUF socket send buffer size hint in bytes." + } + } + } + } + }, + "reconnection-policy": { + "description": "Defines how connection attempts to a node are retried after a connection failure.", + "oneOf": [ + { + "type": "object", + "description": "Exponential backoff reconnection policy. max-ms MUST be greater than or equal to base-ms; this cross-property invariant must be checked by the producer or consumer because JSON Schema Draft 2020-12 cannot compare sibling numeric values.", + "additionalProperties": false, + "required": [ + "type", + "base-ms", + "max-ms" + ], + "properties": { + "type": { + "const": "exponential", + "description": "Reconnection policy type." + }, + "base-ms": { + "$ref": "#/$defs/positiveInteger", + "description": "Initial delay before the first reconnection attempt in milliseconds. Always a concrete value when this policy is reported." + }, + "max-ms": { + "$ref": "#/$defs/positiveInteger", + "description": "Maximum delay between reconnection attempts in milliseconds. MUST be greater than or equal to base-ms. Always a concrete value when this policy is reported." + }, + "max-attempts": { + "$ref": "#/$defs/positiveInteger", + "description": "Maximum number of reconnection attempts before giving up. Absent when attempts are unlimited." + } + } + }, + { + "type": "object", + "description": "Constant-delay reconnection policy. A delay of 0 means reconnect immediately.", + "additionalProperties": false, + "required": [ + "type", + "delay-ms" + ], + "properties": { + "type": { + "const": "constant", + "description": "Reconnection policy type." + }, + "delay-ms": { + "$ref": "#/$defs/nonNegativeInteger", + "description": "Fixed delay between reconnection attempts in milliseconds; 0 means reconnect immediately. Always a concrete value when this policy is reported." + }, + "max-attempts": { + "$ref": "#/$defs/positiveInteger", + "description": "Maximum number of reconnection attempts before giving up. Absent when attempts are unlimited." + } + } + }, + { + "type": "object", + "description": "A user-supplied reconnection policy that is not one of the built-ins. Identified by `name` only. Implementations that can introspect a policy instance MAY also serialize its public attributes as additional properties on this object.", + "additionalProperties": true, + "required": [ + "type", + "name" + ], + "properties": { + "type": { + "const": "custom", + "description": "Reconnection policy type: a user-supplied policy." + }, + "name": { + "$ref": "#/$defs/nonEmptyString", + "description": "Name of the custom policy (e.g. the public type name of the user-provided policy)." + } + } + }, + { + "type": "null", + "description": "No reconnection attempts will be made." + } + ] + }, + "retry-policy": { + "description": "Controls whether and how a failed query is retried. Discriminated on `type`; each policy only permits its own parameters.", + "oneOf": [ + { + "type": "object", + "description": "Standard error-aware retry policy.", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "type": { + "const": "standard-error-aware", + "description": "Retry policy type." + }, + "max-retries": { + "$ref": "#/$defs/nonNegativeInteger", + "description": "Maximum number of retries before giving up; 0 means no retries. Absent when no explicit retry limit is configured." + } + } + }, + { + "type": "object", + "description": "Simple retry policy with a fixed number of retries.", + "additionalProperties": false, + "required": [ + "type", + "max-retries" + ], + "properties": { + "type": { + "const": "simple", + "description": "Retry policy type." + }, + "max-retries": { + "$ref": "#/$defs/nonNegativeInteger", + "description": "Maximum number of retries before giving up. Always a concrete value when this policy is reported; 0 means no retries." + } + } + }, + { + "type": "object", + "description": "Fall-through retry policy: never retries anything and always rethrows the original error to the caller. Every error type — read timeout, write timeout, unavailable, and unexpected request errors (connection errors, Overloaded, ServerError, Bootstrapping) — is propagated unchanged. This is a true no-op and is stricter than the 'never' policy, which still retries the next host on connection/server errors.", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "type": { + "const": "fallthrough", + "description": "Retry policy type." + } + } + }, + { + "type": "object", + "description": "Never-retry policy: does not retry read timeouts, write timeouts, or unavailable errors, but may try the next host for connection and server errors.", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "type": { + "const": "never", + "description": "Retry policy type." + }, + "max-retries": { + "$ref": "#/$defs/nonNegativeInteger", + "description": "Maximum number of retries before giving up; 0 means no retries. Absent when no explicit retry limit is configured." + } + } + }, + { + "type": "object", + "description": "Downgrading-consistency retry policy: retries at a lower consistency level on failure.", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "type": { + "const": "downgrading-consistency", + "description": "Retry policy type." + }, + "max-retries": { + "$ref": "#/$defs/nonNegativeInteger", + "description": "Maximum number of retries before giving up; 0 means no retries. Absent when no explicit retry limit is configured." + } + } + }, + { + "type": "object", + "description": "A user-supplied retry policy that is not one of the built-ins. Identified by `name` only. Implementations that can introspect a policy instance MAY also serialize its public attributes as additional properties on this object.", + "additionalProperties": true, + "required": [ + "type", + "name" + ], + "properties": { + "type": { + "const": "custom", + "description": "Retry policy type: a user-supplied policy." + }, + "name": { + "$ref": "#/$defs/nonEmptyString", + "description": "Name of the custom policy (e.g. the public type name of the user-provided policy)." + }, + "description": { + "$ref": "#/$defs/nonEmptyString", + "description": "Textual description of what this policy does." + }, + "max-retries": { + "$ref": "#/$defs/nonNegativeInteger", + "description": "Maximum number of retries before giving up; 0 means no retries. Absent when no explicit retry limit is configured." + } + } + } + ] + }, + "speculative-execution-policy": { + "description": "Controls pre-emptive duplicate requests to other replicas. Discriminated on `type`; each policy only permits its own parameters.", + "oneOf": [ + { + "type": "object", + "description": "Constant-delay speculative execution: launch extra executions after a fixed delay. A delay of 0 means launch them immediately.", + "additionalProperties": false, + "required": [ + "type", + "max-executions", + "delay-ms" + ], + "properties": { + "type": { + "const": "constant", + "description": "Speculative execution policy type." + }, + "max-executions": { + "$ref": "#/$defs/positiveInteger", + "description": "Maximum number of speculative executions per request." + }, + "delay-ms": { + "$ref": "#/$defs/nonNegativeInteger", + "description": "Delay before launching each additional execution in milliseconds; 0 means launch immediately." + } + } + }, + { + "type": "object", + "description": "Percentile-based speculative execution: launch extra executions once latency exceeds a percentile threshold.", + "additionalProperties": false, + "required": [ + "type", + "max-executions", + "percentile" + ], + "properties": { + "type": { + "const": "percentile", + "description": "Speculative execution policy type." + }, + "max-executions": { + "$ref": "#/$defs/positiveInteger", + "description": "Maximum number of speculative executions per request." + }, + "percentile": { + "type": "number", + "exclusiveMinimum": 0, + "exclusiveMaximum": 100, + "description": "Latency percentile (0–100, exclusive; e.g. 99.0) that triggers an additional execution." + } + } + }, + { + "type": "object", + "description": "A user-supplied speculative execution policy that is not one of the built-ins. Identified by `name` only. Implementations that can introspect a policy instance MAY also serialize its public attributes as additional properties on this object.", + "additionalProperties": true, + "required": [ + "type", + "name" + ], + "properties": { + "type": { + "const": "custom", + "description": "Speculative execution policy type: a user-supplied policy." + }, + "name": { + "$ref": "#/$defs/nonEmptyString", + "description": "Name of the custom policy (e.g. the public type name of the user-provided policy)." + }, + "description": { + "$ref": "#/$defs/nonEmptyString", + "description": "Textual description of what this policy does." + } + } + } + ] + }, + "adaptive-ordering": { + "type": "object", + "description": "Dynamic reordering of otherwise eligible candidate nodes using runtime responsiveness, load, or health observations. Absent when adaptive ordering is disabled. This capability does not imply a particular algorithm.", + "additionalProperties": false, + "required": [ + "signals" + ], + "properties": { + "signals": { + "type": "array", + "description": "Runtime observations used to influence ordering.", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "latency", + "response-rate", + "in-flight-requests", + "recovery-state" + ] + } + } + } + }, + "load-balancing-policy": { + "description": "Load balancing / host selection policy, discriminated by `type`. A built-in token-aware policy is reported with `type` set to `token-aware` and the normalized capability flags below. A user-supplied policy is reported with `type` set to `custom`, a `name`, and, optionally, serialized public attributes.", + "oneOf": [ + { + "type": "object", + "description": "A built-in load balancing policy, reported with normalized location/awareness flags.", + "additionalProperties": false, + "required": [ + "type", + "load-distribution", + "fallback-to-non-preferred-nodes" + ], + "properties": { + "type": { + "const": "token-aware", + "description": "Load balancing policy type: the built-in token-aware policy." + }, + "load-distribution": { + "type": "string", + "enum": [ + "shuffle", + "round-robin", + "replica-set" + ], + "description": "Strategy used to distribute requests across otherwise equally preferred nodes. `shuffle` randomizes node selection across query plans; `round-robin` rotates the first selected node across successive query plans; `replica-set` preserves the replica set's existing order without reordering it." + }, + "fallback-to-non-preferred-nodes": { + "type": "boolean", + "description": "Whether requests may fail over to nodes outside of the preference configured by `query.load-balancing.node-preference`." + }, + "adaptive-ordering": { + "$ref": "#/$defs/adaptive-ordering" + } + } + }, + { + "type": "object", + "description": "A user-supplied load balancing policy that is not one of the built-ins. Identified by `name` only. Implementations that can introspect a policy instance MAY also serialize its public attributes as additional properties on this object.", + "additionalProperties": true, + "required": [ + "type", + "name" + ], + "properties": { + "type": { + "const": "custom", + "description": "Load balancing policy type: a user-supplied policy." + }, + "name": { + "$ref": "#/$defs/nonEmptyString", + "description": "Name of the custom policy (e.g. the public type name of the user-provided policy)." + }, + "description": { + "$ref": "#/$defs/nonEmptyString", + "description": "Textual description of what this policy does." + } + } + } + ] + }, + "node-location-preference": { + "description": "Session-level datacenter/rack preference, set independently of the load balancing policy. Some implementations let users set a preferred DC/rack directly on the session configuration; the load balancing policy and other components read this preference unless a policy overrides it. May be sourced from different places; if DC/rack preferences are specified in the load balancing policy, they should be reported here.", + "oneOf": [ + { + "type": "object", + "description": "Explicitly configured datacenter preference.", + "additionalProperties": false, + "required": [ + "type", + "local-dc" + ], + "properties": { + "type": { + "const": "dc", + "description": "Session-level location preference: explicit datacenter." + }, + "local-dc": { + "$ref": "#/$defs/nonEmptyString", + "description": "Explicitly configured preferred datacenter." + } + } + }, + { + "type": "object", + "description": "Explicitly configured datacenter and rack preference.", + "additionalProperties": false, + "required": [ + "type", + "local-dc", + "local-rack" + ], + "properties": { + "type": { + "const": "rack", + "description": "Session-level location preference: explicit datacenter and rack." + }, + "local-dc": { + "$ref": "#/$defs/nonEmptyString", + "description": "Explicitly configured preferred datacenter." + }, + "local-rack": { + "$ref": "#/$defs/nonEmptyString", + "description": "Explicitly configured preferred rack." + } + } + }, + { + "type": "object", + "description": "Datacenter preference inferred from the first node the client connects to.", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "type": { + "const": "dc-auto", + "description": "Session-level location preference: inferred datacenter." + }, + "local-dc": { + "$ref": "#/$defs/nonEmptyString", + "description": "Inferred preferred datacenter. Absent when not yet known at report time." + } + } + }, + { + "type": "object", + "description": "Datacenter and/or rack preference inferred from the connected node. Configured and inferred values are reported separately.", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "type": { + "const": "rack-auto", + "description": "At least one part of the location preference is inferred." + }, + "local-dc": { + "$ref": "#/$defs/nonEmptyString", + "description": "Explicitly configured preferred datacenter." + }, + "local-rack": { + "$ref": "#/$defs/nonEmptyString", + "description": "Explicitly configured preferred rack." + }, + "inferred-local-dc": { + "$ref": "#/$defs/nonEmptyString", + "description": "Inferred preferred datacenter. Absent when not yet known." + }, + "inferred-local-rack": { + "$ref": "#/$defs/nonEmptyString", + "description": "Inferred preferred rack. Absent when not yet known." + } + }, + "allOf": [ + { + "not": { + "required": [ + "local-dc", + "inferred-local-dc" + ] + } + }, + { + "not": { + "required": [ + "local-rack", + "inferred-local-rack" + ] + } + }, + { + "not": { + "required": [ + "local-dc", + "local-rack" + ] + } + } + ] + } + ] + }, + "query": { + "description": "Query execution configuration.", + "type": "object", + "required": [ + "defaults", + "retry", + "load-balancing" + ], + "additionalProperties": false, + "properties": { + "defaults": { + "$ref": "#/$defs/query-defaults" + }, + "retry": { + "description": "Query retry configuration. Backoff is optional and is omitted when no retry delay is configured.", + "type": "object", + "required": [ + "policy" + ], + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "policy": { + "properties": { + "type": { + "const": "fallthrough" + } + }, + "required": [ + "type" + ] + } + }, + "required": [ + "policy" + ] + }, + "then": { + "not": { + "required": [ + "backoff" + ] + } + } + } + ], + "properties": { + "policy": { + "$ref": "#/$defs/retry-policy" + }, + "backoff": { + "$ref": "#/$defs/retryPolicyBackoff", + "description": "Delay inserted between retries. Omitted when no retry backoff is configured. Every configured delay must be greater than 0." + } + } + }, + "load-balancing": { + "description": "Load-balancing configuration applied to queries.", + "type": "object", + "required": [ + "policy" + ], + "additionalProperties": false, + "properties": { + "policy": { + "$ref": "#/$defs/load-balancing-policy" + }, + "node-preference": { + "$ref": "#/$defs/node-location-preference", + "description": "Defines part of the cluster queries will be scheduled on" + } + } + }, + "speculative-execution": { + "description": "Speculative-execution configuration applied to queries. Absent when speculative execution is disabled.", + "type": "object", + "required": [ + "policy" + ], + "additionalProperties": false, + "properties": { + "policy": { + "$ref": "#/$defs/speculative-execution-policy" + } + } + } + } + }, + "query-defaults": { + "description": "Default per-request settings applied to statements that do not override them.", + "type": "object", + "required": [ + "consistency", + "idempotence" + ], + "additionalProperties": false, + "properties": { + "page": { + "type": "object", + "required": [ + "size" + ], + "additionalProperties": false, + "properties": { + "size": { + "$ref": "#/$defs/positiveInteger", + "description": "Default page (fetch) size for result sets. Absent when page is not limited." + } + } + }, + "consistency": { + "description": "Default consistency level applied to requests that do not override it. Always present when this group is reported.", + "type": "string", + "enum": [ + "ANY", + "ONE", + "TWO", + "THREE", + "QUORUM", + "ALL", + "LOCAL_QUORUM", + "EACH_QUORUM", + "LOCAL_ONE", + "SERIAL", + "LOCAL_SERIAL" + ] + }, + "serial-consistency": { + "description": "Default serial consistency for LWT/conditional statements. Absent when unset; the server default applies.", + "type": "string", + "enum": [ + "SERIAL", + "LOCAL_SERIAL" + ] + }, + "idempotence": { + "description": "Default idempotence flag applied to statements that do not set their own.", + "type": "boolean" + }, + "client-timestamps": { + "description": "True when the client assigns the write timestamp client-side (protocol-level/USING TIMESTAMP) instead of letting the coordinator assign it. Absent only when this behavior is unknown, for example when a custom timestamp generator may or may not enforce a timestamp.", + "type": "boolean" + }, + "request": { + "type": "object", + "description": "Default request-level settings.", + "additionalProperties": false, + "properties": { + "timeout-ms": { + "$ref": "#/$defs/positiveInteger", + "description": "Client-side timeout for a single request/query in milliseconds. Absent when the timeout is disabled or unset." + } + } + } + } + }, + "tls": { + "description": "TLS/SSL transport settings. Absent when TLS is disabled. Reports only booleans; never credentials, keys, or host lists.", + "type": "object", + "additionalProperties": false, + "properties": { + "hostname-verification": { + "type": "boolean", + "description": "Whether the server hostname is verified against its certificate. Absent only when this behavior is unknown, for example when a custom certificate validator may or may not enforce hostname verification." + } + } + } + } +} diff --git a/tests/unit/test_driver_config_schema.py b/tests/unit/test_driver_config_schema.py new file mode 100644 index 0000000000..46c5d82fdc --- /dev/null +++ b/tests/unit/test_driver_config_schema.py @@ -0,0 +1,191 @@ +# Copyright 2026 ScyllaDB, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Tests of the vendored schema and of the helper that validates against it. + +These do not exercise :class:`~.DriverConfigReporter`; they establish that the +contract is being enforced at all, so that the tests which do exercise it are +worth something. What the driver actually reports is checked in +``test_driver_config.py``. +""" + +import copy +import json +import unittest + +import jsonschema +import pytest + +from tests.driver_config_schema import SCHEMA_PATH, load_schema, validate_report + +MINIMAL_REPORT = { + 'version': 1, + 'connection': { + 'connect': {}, + 'requests': {'in-flight': {'max': 1}}, + 'pool': {'shard-aware': {'enabled': False}}, + 'socket': {'tcp-no-delay': False, 'keep-alive': False, 'reuse-address': False}, + 'reconnection': {'policy': None}, + }, + 'control-plane': { + 'queries': {'system': {'timeout': {}}}, + 'schema': {'agreement': {'timeout-ms': 0}}, + }, + 'query': { + 'defaults': {'consistency': 'LOCAL_ONE', 'idempotence': False}, + 'retry': {'policy': {'type': 'fallthrough'}}, + 'load-balancing': {'policy': {'type': 'custom', 'name': 'X'}}, + }, +} +""" +The smallest document the schema accepts: every required group, and in each one +only the required keys. Spelled out rather than generated, so that a change to +what the shared contract demands shows up here as a diff. +""" + + +class DriverConfigSchemaTest(unittest.TestCase): + def test_the_vendored_schema_is_the_shared_one(self): + """ + The schema is vendored from upstream, where it is maintained. Its $id is + what a consumer keys off, so a copy that lost it is not the contract. + """ + schema = load_schema() + + assert schema['$id'] == 'https://scylladb.com/schemas/driver-client-options/v1.json' + assert schema['$schema'] == 'https://json-schema.org/draft/2020-12/schema' + + def test_the_vendored_schema_is_itself_valid(self): + jsonschema.Draft202012Validator.check_schema(load_schema()) + + def test_the_vendored_copy_is_byte_for_byte(self): + """ + Vendored verbatim, so that drift from upstream is a diff in the resource + rather than a reinterpretation. Reformatting it would defeat that, so + this pins the formatting the shared copy has. + """ + with open(SCHEMA_PATH, encoding='utf8') as f: + raw = f.read() + + assert raw.startswith('{\n "$schema"') + assert raw.endswith('}\n') + # Reserialising with the shared copy's formatting must be a no-op. The + # descriptions contain em dashes, which the shared copy leaves as they + # are rather than escaping. + assert json.dumps(json.loads(raw), indent=2, ensure_ascii=False) + '\n' == raw + + def test_the_minimal_report_validates(self): + assert validate_report(MINIMAL_REPORT) == MINIMAL_REPORT + + def test_a_report_is_accepted_as_wire_text(self): + """ + The helper takes what goes on the wire and comes back out of the clients + table, not only an already parsed document. + """ + assert validate_report(json.dumps(MINIMAL_REPORT)) == MINIMAL_REPORT + + def _rejects(self, mutate): + report = copy.deepcopy(MINIMAL_REPORT) + mutate(report) + with pytest.raises(jsonschema.ValidationError): + validate_report(report) + + def test_unknown_keys_are_rejected(self): + """ + Every built-in group is additionalProperties: false, so a key this driver + invents or misspells fails validation instead of being ignored by a + consumer. This is the property that makes the schema worth validating + against at all. + """ + def top_level(report): + report['made-up'] = 1 + + def inside_a_group(report): + report['connection']['made-up'] = 1 + + def inside_a_nested_group(report): + report['query']['defaults']['made-up'] = 1 + + for mutate in (top_level, inside_a_group, inside_a_nested_group): + self._rejects(mutate) + + def test_missing_required_groups_are_rejected(self): + for group in ('connection', 'control-plane', 'query'): + self._rejects(lambda report, group=group: report.pop(group)) + + def test_a_foreign_schema_version_is_rejected(self): + for version in (0, 2, '1'): + self._rejects(lambda report, version=version: report.__setitem__('version', version)) + + def test_out_of_range_numbers_are_rejected(self): + def zero_in_flight(report): + # positiveInteger: 0 in-flight requests would describe a connection + # that cannot carry a request. + report['connection']['requests']['in-flight']['max'] = 0 + + def negative_agreement_timeout(report): + # nonNegativeInteger: 0 is meaningful here, below that is not. + report['control-plane']['schema']['agreement']['timeout-ms'] = -1 + + for mutate in (zero_in_flight, negative_agreement_timeout): + self._rejects(mutate) + + def test_unknown_enum_members_are_rejected(self): + self._rejects(lambda report: report['query']['defaults'].__setitem__('consistency', 'MOSTLY')) + + def test_a_consistency_level_is_not_a_number(self): + """ + The wire form of a consistency level is an integer and the schema wants + the name, which is the mistake this driver is closest to making. + """ + self._rejects(lambda report: report['query']['defaults'].__setitem__('consistency', 4)) + + def test_discriminated_unions_reject_foreign_parameters(self): + def constant_delay_on_an_exponential_policy(report): + report['connection']['reconnection']['policy'] = { + 'type': 'exponential', 'base-ms': 1, 'max-ms': 2, 'delay-ms': 3} + + def a_custom_policy_without_a_name(report): + report['query']['load-balancing']['policy'] = {'type': 'custom'} + + for mutate in (constant_delay_on_an_exponential_policy, a_custom_policy_without_a_name): + self._rejects(mutate) + + def test_backoff_is_rejected_on_a_fallthrough_retry_policy(self): + """ + A policy that never retries cannot have a delay between retries. The + schema says so conditionally, which is the one rule a producer is likely + to break without noticing. + """ + self._rejects(lambda report: report['query']['retry'].__setitem__( + 'backoff', {'type': 'constant', 'delay-ms': 1})) + + def test_the_orphan_bound_is_optional_but_permitted(self): + """ + The shared schema leaves connection.requests.orphaned optional, for a + client with nothing bounding its orphaned requests. This driver has such + a bound in Connection.orphaned_threshold and reports it; optional is not + forbidden, so both documents have to validate. + """ + without = copy.deepcopy(MINIMAL_REPORT) + assert 'orphaned' not in without['connection']['requests'] + validate_report(without) + + with_bound = copy.deepcopy(MINIMAL_REPORT) + with_bound['connection']['requests']['orphaned'] = {'max': 0} + validate_report(with_bound) + + # Present but empty is still a violation: the group exists to carry the + # bound, so it may be absent but not uninformative. + self._rejects(lambda report: report['connection']['requests'].__setitem__('orphaned', {})) diff --git a/uv.lock b/uv.lock index 216e808caa..d8822e0ad5 100644 --- a/uv.lock +++ b/uv.lock @@ -1604,6 +1604,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, ] +[[package]] +name = "jsonschema" +version = "4.25.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9.12' and python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.9.12'", + "python_full_version <= '3.9'", +] +dependencies = [ + { name = "attrs", marker = "python_full_version < '3.10'" }, + { name = "jsonschema-specifications", marker = "python_full_version < '3.10'" }, + { name = "referencing", version = "0.36.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "rpds-py", version = "0.27.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "attrs", marker = "python_full_version >= '3.10'" }, + { name = "jsonschema-specifications", marker = "python_full_version >= '3.10'" }, + { name = "referencing", version = "0.37.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing", version = "0.36.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "referencing", version = "0.37.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + [[package]] name = "kerberos" version = "1.3.1" @@ -2581,6 +2635,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777, upload-time = "2025-09-25T21:33:15.55Z" }, ] +[[package]] +name = "referencing" +version = "0.36.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9.12' and python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.9.12'", + "python_full_version <= '3.9'", +] +dependencies = [ + { name = "attrs", marker = "python_full_version < '3.10'" }, + { name = "rpds-py", version = "0.27.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "attrs", marker = "python_full_version >= '3.10'" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + [[package]] name = "requests" version = "2.32.5" @@ -2621,6 +2714,425 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] +[[package]] +name = "rpds-py" +version = "0.27.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9.12' and python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.9.12'", + "python_full_version <= '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/dd/2c0cbe774744272b0ae725f44032c77bdcab6e8bcf544bffa3b6e70c8dba/rpds_py-0.27.1.tar.gz", hash = "sha256:26a1c73171d10b7acccbded82bf6a586ab8203601e565badc74bbbf8bc5a10f8", size = 27479, upload-time = "2025-08-27T12:16:36.024Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/ed/3aef893e2dd30e77e35d20d4ddb45ca459db59cead748cad9796ad479411/rpds_py-0.27.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:68afeec26d42ab3b47e541b272166a0b4400313946871cba3ed3a4fc0cab1cef", size = 371606, upload-time = "2025-08-27T12:12:25.189Z" }, + { url = "https://files.pythonhosted.org/packages/6d/82/9818b443e5d3eb4c83c3994561387f116aae9833b35c484474769c4a8faf/rpds_py-0.27.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:74e5b2f7bb6fa38b1b10546d27acbacf2a022a8b5543efb06cfebc72a59c85be", size = 353452, upload-time = "2025-08-27T12:12:27.433Z" }, + { url = "https://files.pythonhosted.org/packages/99/c7/d2a110ffaaa397fc6793a83c7bd3545d9ab22658b7cdff05a24a4535cc45/rpds_py-0.27.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9024de74731df54546fab0bfbcdb49fae19159ecaecfc8f37c18d2c7e2c0bd61", size = 381519, upload-time = "2025-08-27T12:12:28.719Z" }, + { url = "https://files.pythonhosted.org/packages/5a/bc/e89581d1f9d1be7d0247eaef602566869fdc0d084008ba139e27e775366c/rpds_py-0.27.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:31d3ebadefcd73b73928ed0b2fd696f7fefda8629229f81929ac9c1854d0cffb", size = 394424, upload-time = "2025-08-27T12:12:30.207Z" }, + { url = "https://files.pythonhosted.org/packages/ac/2e/36a6861f797530e74bb6ed53495f8741f1ef95939eed01d761e73d559067/rpds_py-0.27.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2e7f8f169d775dd9092a1743768d771f1d1300453ddfe6325ae3ab5332b4657", size = 523467, upload-time = "2025-08-27T12:12:31.808Z" }, + { url = "https://files.pythonhosted.org/packages/c4/59/c1bc2be32564fa499f988f0a5c6505c2f4746ef96e58e4d7de5cf923d77e/rpds_py-0.27.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d905d16f77eb6ab2e324e09bfa277b4c8e5e6b8a78a3e7ff8f3cdf773b4c013", size = 402660, upload-time = "2025-08-27T12:12:33.444Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ec/ef8bf895f0628dd0a59e54d81caed6891663cb9c54a0f4bb7da918cb88cf/rpds_py-0.27.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50c946f048209e6362e22576baea09193809f87687a95a8db24e5fbdb307b93a", size = 384062, upload-time = "2025-08-27T12:12:34.857Z" }, + { url = "https://files.pythonhosted.org/packages/69/f7/f47ff154be8d9a5e691c083a920bba89cef88d5247c241c10b9898f595a1/rpds_py-0.27.1-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:3deab27804d65cd8289eb814c2c0e807c4b9d9916c9225e363cb0cf875eb67c1", size = 401289, upload-time = "2025-08-27T12:12:36.085Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d9/ca410363efd0615814ae579f6829cafb39225cd63e5ea5ed1404cb345293/rpds_py-0.27.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8b61097f7488de4be8244c89915da8ed212832ccf1e7c7753a25a394bf9b1f10", size = 417718, upload-time = "2025-08-27T12:12:37.401Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a0/8cb5c2ff38340f221cc067cc093d1270e10658ba4e8d263df923daa18e86/rpds_py-0.27.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8a3f29aba6e2d7d90528d3c792555a93497fe6538aa65eb675b44505be747808", size = 558333, upload-time = "2025-08-27T12:12:38.672Z" }, + { url = "https://files.pythonhosted.org/packages/6f/8c/1b0de79177c5d5103843774ce12b84caa7164dfc6cd66378768d37db11bf/rpds_py-0.27.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dd6cd0485b7d347304067153a6dc1d73f7d4fd995a396ef32a24d24b8ac63ac8", size = 589127, upload-time = "2025-08-27T12:12:41.48Z" }, + { url = "https://files.pythonhosted.org/packages/c8/5e/26abb098d5e01266b0f3a2488d299d19ccc26849735d9d2b95c39397e945/rpds_py-0.27.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6f4461bf931108c9fa226ffb0e257c1b18dc2d44cd72b125bec50ee0ab1248a9", size = 554899, upload-time = "2025-08-27T12:12:42.925Z" }, + { url = "https://files.pythonhosted.org/packages/de/41/905cc90ced13550db017f8f20c6d8e8470066c5738ba480d7ba63e3d136b/rpds_py-0.27.1-cp310-cp310-win32.whl", hash = "sha256:ee5422d7fb21f6a00c1901bf6559c49fee13a5159d0288320737bbf6585bd3e4", size = 217450, upload-time = "2025-08-27T12:12:44.813Z" }, + { url = "https://files.pythonhosted.org/packages/75/3d/6bef47b0e253616ccdf67c283e25f2d16e18ccddd38f92af81d5a3420206/rpds_py-0.27.1-cp310-cp310-win_amd64.whl", hash = "sha256:3e039aabf6d5f83c745d5f9a0a381d031e9ed871967c0a5c38d201aca41f3ba1", size = 228447, upload-time = "2025-08-27T12:12:46.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c1/7907329fbef97cbd49db6f7303893bd1dd5a4a3eae415839ffdfb0762cae/rpds_py-0.27.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:be898f271f851f68b318872ce6ebebbc62f303b654e43bf72683dbdc25b7c881", size = 371063, upload-time = "2025-08-27T12:12:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/11/94/2aab4bc86228bcf7c48760990273653a4900de89c7537ffe1b0d6097ed39/rpds_py-0.27.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:62ac3d4e3e07b58ee0ddecd71d6ce3b1637de2d373501412df395a0ec5f9beb5", size = 353210, upload-time = "2025-08-27T12:12:49.187Z" }, + { url = "https://files.pythonhosted.org/packages/3a/57/f5eb3ecf434342f4f1a46009530e93fd201a0b5b83379034ebdb1d7c1a58/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4708c5c0ceb2d034f9991623631d3d23cb16e65c83736ea020cdbe28d57c0a0e", size = 381636, upload-time = "2025-08-27T12:12:50.492Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f4/ef95c5945e2ceb5119571b184dd5a1cc4b8541bbdf67461998cfeac9cb1e/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:abfa1171a9952d2e0002aba2ad3780820b00cc3d9c98c6630f2e93271501f66c", size = 394341, upload-time = "2025-08-27T12:12:52.024Z" }, + { url = "https://files.pythonhosted.org/packages/5a/7e/4bd610754bf492d398b61725eb9598ddd5eb86b07d7d9483dbcd810e20bc/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b507d19f817ebaca79574b16eb2ae412e5c0835542c93fe9983f1e432aca195", size = 523428, upload-time = "2025-08-27T12:12:53.779Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e5/059b9f65a8c9149361a8b75094864ab83b94718344db511fd6117936ed2a/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:168b025f8fd8d8d10957405f3fdcef3dc20f5982d398f90851f4abc58c566c52", size = 402923, upload-time = "2025-08-27T12:12:55.15Z" }, + { url = "https://files.pythonhosted.org/packages/f5/48/64cabb7daced2968dd08e8a1b7988bf358d7bd5bcd5dc89a652f4668543c/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb56c6210ef77caa58e16e8c17d35c63fe3f5b60fd9ba9d424470c3400bcf9ed", size = 384094, upload-time = "2025-08-27T12:12:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e1/dc9094d6ff566bff87add8a510c89b9e158ad2ecd97ee26e677da29a9e1b/rpds_py-0.27.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:d252f2d8ca0195faa707f8eb9368955760880b2b42a8ee16d382bf5dd807f89a", size = 401093, upload-time = "2025-08-27T12:12:58.985Z" }, + { url = "https://files.pythonhosted.org/packages/37/8e/ac8577e3ecdd5593e283d46907d7011618994e1d7ab992711ae0f78b9937/rpds_py-0.27.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6e5e54da1e74b91dbc7996b56640f79b195d5925c2b78efaa8c5d53e1d88edde", size = 417969, upload-time = "2025-08-27T12:13:00.367Z" }, + { url = "https://files.pythonhosted.org/packages/66/6d/87507430a8f74a93556fe55c6485ba9c259949a853ce407b1e23fea5ba31/rpds_py-0.27.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ffce0481cc6e95e5b3f0a47ee17ffbd234399e6d532f394c8dce320c3b089c21", size = 558302, upload-time = "2025-08-27T12:13:01.737Z" }, + { url = "https://files.pythonhosted.org/packages/3a/bb/1db4781ce1dda3eecc735e3152659a27b90a02ca62bfeea17aee45cc0fbc/rpds_py-0.27.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a205fdfe55c90c2cd8e540ca9ceba65cbe6629b443bc05db1f590a3db8189ff9", size = 589259, upload-time = "2025-08-27T12:13:03.127Z" }, + { url = "https://files.pythonhosted.org/packages/7b/0e/ae1c8943d11a814d01b482e1f8da903f88047a962dff9bbdadf3bd6e6fd1/rpds_py-0.27.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:689fb5200a749db0415b092972e8eba85847c23885c8543a8b0f5c009b1a5948", size = 554983, upload-time = "2025-08-27T12:13:04.516Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d5/0b2a55415931db4f112bdab072443ff76131b5ac4f4dc98d10d2d357eb03/rpds_py-0.27.1-cp311-cp311-win32.whl", hash = "sha256:3182af66048c00a075010bc7f4860f33913528a4b6fc09094a6e7598e462fe39", size = 217154, upload-time = "2025-08-27T12:13:06.278Z" }, + { url = "https://files.pythonhosted.org/packages/24/75/3b7ffe0d50dc86a6a964af0d1cc3a4a2cdf437cb7b099a4747bbb96d1819/rpds_py-0.27.1-cp311-cp311-win_amd64.whl", hash = "sha256:b4938466c6b257b2f5c4ff98acd8128ec36b5059e5c8f8372d79316b1c36bb15", size = 228627, upload-time = "2025-08-27T12:13:07.625Z" }, + { url = "https://files.pythonhosted.org/packages/8d/3f/4fd04c32abc02c710f09a72a30c9a55ea3cc154ef8099078fd50a0596f8e/rpds_py-0.27.1-cp311-cp311-win_arm64.whl", hash = "sha256:2f57af9b4d0793e53266ee4325535a31ba48e2f875da81a9177c9926dfa60746", size = 220998, upload-time = "2025-08-27T12:13:08.972Z" }, + { url = "https://files.pythonhosted.org/packages/bd/fe/38de28dee5df58b8198c743fe2bea0c785c6d40941b9950bac4cdb71a014/rpds_py-0.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ae2775c1973e3c30316892737b91f9283f9908e3cc7625b9331271eaaed7dc90", size = 361887, upload-time = "2025-08-27T12:13:10.233Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/4b6c7eedc7dd90986bf0fab6ea2a091ec11c01b15f8ba0a14d3f80450468/rpds_py-0.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2643400120f55c8a96f7c9d858f7be0c88d383cd4653ae2cf0d0c88f668073e5", size = 345795, upload-time = "2025-08-27T12:13:11.65Z" }, + { url = "https://files.pythonhosted.org/packages/6f/0e/e650e1b81922847a09cca820237b0edee69416a01268b7754d506ade11ad/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16323f674c089b0360674a4abd28d5042947d54ba620f72514d69be4ff64845e", size = 385121, upload-time = "2025-08-27T12:13:13.008Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ea/b306067a712988e2bff00dcc7c8f31d26c29b6d5931b461aa4b60a013e33/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a1f4814b65eacac94a00fc9a526e3fdafd78e439469644032032d0d63de4881", size = 398976, upload-time = "2025-08-27T12:13:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0a/26dc43c8840cb8fe239fe12dbc8d8de40f2365e838f3d395835dde72f0e5/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ba32c16b064267b22f1850a34051121d423b6f7338a12b9459550eb2096e7ec", size = 525953, upload-time = "2025-08-27T12:13:15.774Z" }, + { url = "https://files.pythonhosted.org/packages/22/14/c85e8127b573aaf3a0cbd7fbb8c9c99e735a4a02180c84da2a463b766e9e/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5c20f33fd10485b80f65e800bbe5f6785af510b9f4056c5a3c612ebc83ba6cb", size = 407915, upload-time = "2025-08-27T12:13:17.379Z" }, + { url = "https://files.pythonhosted.org/packages/ed/7b/8f4fee9ba1fb5ec856eb22d725a4efa3deb47f769597c809e03578b0f9d9/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:466bfe65bd932da36ff279ddd92de56b042f2266d752719beb97b08526268ec5", size = 386883, upload-time = "2025-08-27T12:13:18.704Z" }, + { url = "https://files.pythonhosted.org/packages/86/47/28fa6d60f8b74fcdceba81b272f8d9836ac0340570f68f5df6b41838547b/rpds_py-0.27.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:41e532bbdcb57c92ba3be62c42e9f096431b4cf478da9bc3bc6ce5c38ab7ba7a", size = 405699, upload-time = "2025-08-27T12:13:20.089Z" }, + { url = "https://files.pythonhosted.org/packages/d0/fd/c5987b5e054548df56953a21fe2ebed51fc1ec7c8f24fd41c067b68c4a0a/rpds_py-0.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f149826d742b406579466283769a8ea448eed82a789af0ed17b0cd5770433444", size = 423713, upload-time = "2025-08-27T12:13:21.436Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ba/3c4978b54a73ed19a7d74531be37a8bcc542d917c770e14d372b8daea186/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80c60cfb5310677bd67cb1e85a1e8eb52e12529545441b43e6f14d90b878775a", size = 562324, upload-time = "2025-08-27T12:13:22.789Z" }, + { url = "https://files.pythonhosted.org/packages/b5/6c/6943a91768fec16db09a42b08644b960cff540c66aab89b74be6d4a144ba/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7ee6521b9baf06085f62ba9c7a3e5becffbc32480d2f1b351559c001c38ce4c1", size = 593646, upload-time = "2025-08-27T12:13:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/11/73/9d7a8f4be5f4396f011a6bb7a19fe26303a0dac9064462f5651ced2f572f/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a512c8263249a9d68cac08b05dd59d2b3f2061d99b322813cbcc14c3c7421998", size = 558137, upload-time = "2025-08-27T12:13:25.557Z" }, + { url = "https://files.pythonhosted.org/packages/6e/96/6772cbfa0e2485bcceef8071de7821f81aeac8bb45fbfd5542a3e8108165/rpds_py-0.27.1-cp312-cp312-win32.whl", hash = "sha256:819064fa048ba01b6dadc5116f3ac48610435ac9a0058bbde98e569f9e785c39", size = 221343, upload-time = "2025-08-27T12:13:26.967Z" }, + { url = "https://files.pythonhosted.org/packages/67/b6/c82f0faa9af1c6a64669f73a17ee0eeef25aff30bb9a1c318509efe45d84/rpds_py-0.27.1-cp312-cp312-win_amd64.whl", hash = "sha256:d9199717881f13c32c4046a15f024971a3b78ad4ea029e8da6b86e5aa9cf4594", size = 232497, upload-time = "2025-08-27T12:13:28.326Z" }, + { url = "https://files.pythonhosted.org/packages/e1/96/2817b44bd2ed11aebacc9251da03689d56109b9aba5e311297b6902136e2/rpds_py-0.27.1-cp312-cp312-win_arm64.whl", hash = "sha256:33aa65b97826a0e885ef6e278fbd934e98cdcfed80b63946025f01e2f5b29502", size = 222790, upload-time = "2025-08-27T12:13:29.71Z" }, + { url = "https://files.pythonhosted.org/packages/cc/77/610aeee8d41e39080c7e14afa5387138e3c9fa9756ab893d09d99e7d8e98/rpds_py-0.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e4b9fcfbc021633863a37e92571d6f91851fa656f0180246e84cbd8b3f6b329b", size = 361741, upload-time = "2025-08-27T12:13:31.039Z" }, + { url = "https://files.pythonhosted.org/packages/3a/fc/c43765f201c6a1c60be2043cbdb664013def52460a4c7adace89d6682bf4/rpds_py-0.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1441811a96eadca93c517d08df75de45e5ffe68aa3089924f963c782c4b898cf", size = 345574, upload-time = "2025-08-27T12:13:32.902Z" }, + { url = "https://files.pythonhosted.org/packages/20/42/ee2b2ca114294cd9847d0ef9c26d2b0851b2e7e00bf14cc4c0b581df0fc3/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55266dafa22e672f5a4f65019015f90336ed31c6383bd53f5e7826d21a0e0b83", size = 385051, upload-time = "2025-08-27T12:13:34.228Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e8/1e430fe311e4799e02e2d1af7c765f024e95e17d651612425b226705f910/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d78827d7ac08627ea2c8e02c9e5b41180ea5ea1f747e9db0915e3adf36b62dcf", size = 398395, upload-time = "2025-08-27T12:13:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/82/95/9dc227d441ff2670651c27a739acb2535ccaf8b351a88d78c088965e5996/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae92443798a40a92dc5f0b01d8a7c93adde0c4dc965310a29ae7c64d72b9fad2", size = 524334, upload-time = "2025-08-27T12:13:37.562Z" }, + { url = "https://files.pythonhosted.org/packages/87/01/a670c232f401d9ad461d9a332aa4080cd3cb1d1df18213dbd0d2a6a7ab51/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c46c9dd2403b66a2a3b9720ec4b74d4ab49d4fabf9f03dfdce2d42af913fe8d0", size = 407691, upload-time = "2025-08-27T12:13:38.94Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/0a14aebbaa26fe7fab4780c76f2239e76cc95a0090bdb25e31d95c492fcd/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2efe4eb1d01b7f5f1939f4ef30ecea6c6b3521eec451fb93191bf84b2a522418", size = 386868, upload-time = "2025-08-27T12:13:40.192Z" }, + { url = "https://files.pythonhosted.org/packages/3b/03/8c897fb8b5347ff6c1cc31239b9611c5bf79d78c984430887a353e1409a1/rpds_py-0.27.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:15d3b4d83582d10c601f481eca29c3f138d44c92187d197aff663a269197c02d", size = 405469, upload-time = "2025-08-27T12:13:41.496Z" }, + { url = "https://files.pythonhosted.org/packages/da/07/88c60edc2df74850d496d78a1fdcdc7b54360a7f610a4d50008309d41b94/rpds_py-0.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4ed2e16abbc982a169d30d1a420274a709949e2cbdef119fe2ec9d870b42f274", size = 422125, upload-time = "2025-08-27T12:13:42.802Z" }, + { url = "https://files.pythonhosted.org/packages/6b/86/5f4c707603e41b05f191a749984f390dabcbc467cf833769b47bf14ba04f/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a75f305c9b013289121ec0f1181931975df78738cdf650093e6b86d74aa7d8dd", size = 562341, upload-time = "2025-08-27T12:13:44.472Z" }, + { url = "https://files.pythonhosted.org/packages/b2/92/3c0cb2492094e3cd9baf9e49bbb7befeceb584ea0c1a8b5939dca4da12e5/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:67ce7620704745881a3d4b0ada80ab4d99df390838839921f99e63c474f82cf2", size = 592511, upload-time = "2025-08-27T12:13:45.898Z" }, + { url = "https://files.pythonhosted.org/packages/10/bb/82e64fbb0047c46a168faa28d0d45a7851cd0582f850b966811d30f67ad8/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d992ac10eb86d9b6f369647b6a3f412fc0075cfd5d799530e84d335e440a002", size = 557736, upload-time = "2025-08-27T12:13:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/3c863973d409210da7fb41958172c6b7dbe7fc34e04d3cc1f10bb85e979f/rpds_py-0.27.1-cp313-cp313-win32.whl", hash = "sha256:4f75e4bd8ab8db624e02c8e2fc4063021b58becdbe6df793a8111d9343aec1e3", size = 221462, upload-time = "2025-08-27T12:13:48.742Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2c/5867b14a81dc217b56d95a9f2a40fdbc56a1ab0181b80132beeecbd4b2d6/rpds_py-0.27.1-cp313-cp313-win_amd64.whl", hash = "sha256:f9025faafc62ed0b75a53e541895ca272815bec18abe2249ff6501c8f2e12b83", size = 232034, upload-time = "2025-08-27T12:13:50.11Z" }, + { url = "https://files.pythonhosted.org/packages/c7/78/3958f3f018c01923823f1e47f1cc338e398814b92d83cd278364446fac66/rpds_py-0.27.1-cp313-cp313-win_arm64.whl", hash = "sha256:ed10dc32829e7d222b7d3b93136d25a406ba9788f6a7ebf6809092da1f4d279d", size = 222392, upload-time = "2025-08-27T12:13:52.587Z" }, + { url = "https://files.pythonhosted.org/packages/01/76/1cdf1f91aed5c3a7bf2eba1f1c4e4d6f57832d73003919a20118870ea659/rpds_py-0.27.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:92022bbbad0d4426e616815b16bc4127f83c9a74940e1ccf3cfe0b387aba0228", size = 358355, upload-time = "2025-08-27T12:13:54.012Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6f/bf142541229374287604caf3bb2a4ae17f0a580798fd72d3b009b532db4e/rpds_py-0.27.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:47162fdab9407ec3f160805ac3e154df042e577dd53341745fc7fb3f625e6d92", size = 342138, upload-time = "2025-08-27T12:13:55.791Z" }, + { url = "https://files.pythonhosted.org/packages/1a/77/355b1c041d6be40886c44ff5e798b4e2769e497b790f0f7fd1e78d17e9a8/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb89bec23fddc489e5d78b550a7b773557c9ab58b7946154a10a6f7a214a48b2", size = 380247, upload-time = "2025-08-27T12:13:57.683Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a4/d9cef5c3946ea271ce2243c51481971cd6e34f21925af2783dd17b26e815/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e48af21883ded2b3e9eb48cb7880ad8598b31ab752ff3be6457001d78f416723", size = 390699, upload-time = "2025-08-27T12:13:59.137Z" }, + { url = "https://files.pythonhosted.org/packages/3a/06/005106a7b8c6c1a7e91b73169e49870f4af5256119d34a361ae5240a0c1d/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f5b7bd8e219ed50299e58551a410b64daafb5017d54bbe822e003856f06a802", size = 521852, upload-time = "2025-08-27T12:14:00.583Z" }, + { url = "https://files.pythonhosted.org/packages/e5/3e/50fb1dac0948e17a02eb05c24510a8fe12d5ce8561c6b7b7d1339ab7ab9c/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08f1e20bccf73b08d12d804d6e1c22ca5530e71659e6673bce31a6bb71c1e73f", size = 402582, upload-time = "2025-08-27T12:14:02.034Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b0/f4e224090dc5b0ec15f31a02d746ab24101dd430847c4d99123798661bfc/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dc5dceeaefcc96dc192e3a80bbe1d6c410c469e97bdd47494a7d930987f18b2", size = 384126, upload-time = "2025-08-27T12:14:03.437Z" }, + { url = "https://files.pythonhosted.org/packages/54/77/ac339d5f82b6afff1df8f0fe0d2145cc827992cb5f8eeb90fc9f31ef7a63/rpds_py-0.27.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:d76f9cc8665acdc0c9177043746775aa7babbf479b5520b78ae4002d889f5c21", size = 399486, upload-time = "2025-08-27T12:14:05.443Z" }, + { url = "https://files.pythonhosted.org/packages/d6/29/3e1c255eee6ac358c056a57d6d6869baa00a62fa32eea5ee0632039c50a3/rpds_py-0.27.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:134fae0e36022edad8290a6661edf40c023562964efea0cc0ec7f5d392d2aaef", size = 414832, upload-time = "2025-08-27T12:14:06.902Z" }, + { url = "https://files.pythonhosted.org/packages/3f/db/6d498b844342deb3fa1d030598db93937a9964fcf5cb4da4feb5f17be34b/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb11a4f1b2b63337cfd3b4d110af778a59aae51c81d195768e353d8b52f88081", size = 557249, upload-time = "2025-08-27T12:14:08.37Z" }, + { url = "https://files.pythonhosted.org/packages/60/f3/690dd38e2310b6f68858a331399b4d6dbb9132c3e8ef8b4333b96caf403d/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:13e608ac9f50a0ed4faec0e90ece76ae33b34c0e8656e3dceb9a7db994c692cd", size = 587356, upload-time = "2025-08-27T12:14:10.034Z" }, + { url = "https://files.pythonhosted.org/packages/86/e3/84507781cccd0145f35b1dc32c72675200c5ce8d5b30f813e49424ef68fc/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dd2135527aa40f061350c3f8f89da2644de26cd73e4de458e79606384f4f68e7", size = 555300, upload-time = "2025-08-27T12:14:11.783Z" }, + { url = "https://files.pythonhosted.org/packages/e5/ee/375469849e6b429b3516206b4580a79e9ef3eb12920ddbd4492b56eaacbe/rpds_py-0.27.1-cp313-cp313t-win32.whl", hash = "sha256:3020724ade63fe320a972e2ffd93b5623227e684315adce194941167fee02688", size = 216714, upload-time = "2025-08-27T12:14:13.629Z" }, + { url = "https://files.pythonhosted.org/packages/21/87/3fc94e47c9bd0742660e84706c311a860dcae4374cf4a03c477e23ce605a/rpds_py-0.27.1-cp313-cp313t-win_amd64.whl", hash = "sha256:8ee50c3e41739886606388ba3ab3ee2aae9f35fb23f833091833255a31740797", size = 228943, upload-time = "2025-08-27T12:14:14.937Z" }, + { url = "https://files.pythonhosted.org/packages/70/36/b6e6066520a07cf029d385de869729a895917b411e777ab1cde878100a1d/rpds_py-0.27.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:acb9aafccaae278f449d9c713b64a9e68662e7799dbd5859e2c6b3c67b56d334", size = 362472, upload-time = "2025-08-27T12:14:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/af/07/b4646032e0dcec0df9c73a3bd52f63bc6c5f9cda992f06bd0e73fe3fbebd/rpds_py-0.27.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b7fb801aa7f845ddf601c49630deeeccde7ce10065561d92729bfe81bd21fb33", size = 345676, upload-time = "2025-08-27T12:14:17.764Z" }, + { url = "https://files.pythonhosted.org/packages/b0/16/2f1003ee5d0af4bcb13c0cf894957984c32a6751ed7206db2aee7379a55e/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe0dd05afb46597b9a2e11c351e5e4283c741237e7f617ffb3252780cca9336a", size = 385313, upload-time = "2025-08-27T12:14:19.829Z" }, + { url = "https://files.pythonhosted.org/packages/05/cd/7eb6dd7b232e7f2654d03fa07f1414d7dfc980e82ba71e40a7c46fd95484/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b6dfb0e058adb12d8b1d1b25f686e94ffa65d9995a5157afe99743bf7369d62b", size = 399080, upload-time = "2025-08-27T12:14:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/20/51/5829afd5000ec1cb60f304711f02572d619040aa3ec033d8226817d1e571/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed090ccd235f6fa8bb5861684567f0a83e04f52dfc2e5c05f2e4b1309fcf85e7", size = 523868, upload-time = "2025-08-27T12:14:23.485Z" }, + { url = "https://files.pythonhosted.org/packages/05/2c/30eebca20d5db95720ab4d2faec1b5e4c1025c473f703738c371241476a2/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bf876e79763eecf3e7356f157540d6a093cef395b65514f17a356f62af6cc136", size = 408750, upload-time = "2025-08-27T12:14:24.924Z" }, + { url = "https://files.pythonhosted.org/packages/90/1a/cdb5083f043597c4d4276eae4e4c70c55ab5accec078da8611f24575a367/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:12ed005216a51b1d6e2b02a7bd31885fe317e45897de81d86dcce7d74618ffff", size = 387688, upload-time = "2025-08-27T12:14:27.537Z" }, + { url = "https://files.pythonhosted.org/packages/7c/92/cf786a15320e173f945d205ab31585cc43969743bb1a48b6888f7a2b0a2d/rpds_py-0.27.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:ee4308f409a40e50593c7e3bb8cbe0b4d4c66d1674a316324f0c2f5383b486f9", size = 407225, upload-time = "2025-08-27T12:14:28.981Z" }, + { url = "https://files.pythonhosted.org/packages/33/5c/85ee16df5b65063ef26017bef33096557a4c83fbe56218ac7cd8c235f16d/rpds_py-0.27.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0b08d152555acf1f455154d498ca855618c1378ec810646fcd7c76416ac6dc60", size = 423361, upload-time = "2025-08-27T12:14:30.469Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8e/1c2741307fcabd1a334ecf008e92c4f47bb6f848712cf15c923becfe82bb/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dce51c828941973a5684d458214d3a36fcd28da3e1875d659388f4f9f12cc33e", size = 562493, upload-time = "2025-08-27T12:14:31.987Z" }, + { url = "https://files.pythonhosted.org/packages/04/03/5159321baae9b2222442a70c1f988cbbd66b9be0675dd3936461269be360/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c1476d6f29eb81aa4151c9a31219b03f1f798dc43d8af1250a870735516a1212", size = 592623, upload-time = "2025-08-27T12:14:33.543Z" }, + { url = "https://files.pythonhosted.org/packages/ff/39/c09fd1ad28b85bc1d4554a8710233c9f4cefd03d7717a1b8fbfd171d1167/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3ce0cac322b0d69b63c9cdb895ee1b65805ec9ffad37639f291dd79467bee675", size = 558800, upload-time = "2025-08-27T12:14:35.436Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d6/99228e6bbcf4baa764b18258f519a9035131d91b538d4e0e294313462a98/rpds_py-0.27.1-cp314-cp314-win32.whl", hash = "sha256:dfbfac137d2a3d0725758cd141f878bf4329ba25e34979797c89474a89a8a3a3", size = 221943, upload-time = "2025-08-27T12:14:36.898Z" }, + { url = "https://files.pythonhosted.org/packages/be/07/c802bc6b8e95be83b79bdf23d1aa61d68324cb1006e245d6c58e959e314d/rpds_py-0.27.1-cp314-cp314-win_amd64.whl", hash = "sha256:a6e57b0abfe7cc513450fcf529eb486b6e4d3f8aee83e92eb5f1ef848218d456", size = 233739, upload-time = "2025-08-27T12:14:38.386Z" }, + { url = "https://files.pythonhosted.org/packages/c8/89/3e1b1c16d4c2d547c5717377a8df99aee8099ff050f87c45cb4d5fa70891/rpds_py-0.27.1-cp314-cp314-win_arm64.whl", hash = "sha256:faf8d146f3d476abfee026c4ae3bdd9ca14236ae4e4c310cbd1cf75ba33d24a3", size = 223120, upload-time = "2025-08-27T12:14:39.82Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/dc7931dc2fa4a6e46b2a4fa744a9fe5c548efd70e0ba74f40b39fa4a8c10/rpds_py-0.27.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:ba81d2b56b6d4911ce735aad0a1d4495e808b8ee4dc58715998741a26874e7c2", size = 358944, upload-time = "2025-08-27T12:14:41.199Z" }, + { url = "https://files.pythonhosted.org/packages/e6/22/4af76ac4e9f336bfb1a5f240d18a33c6b2fcaadb7472ac7680576512b49a/rpds_py-0.27.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:84f7d509870098de0e864cad0102711c1e24e9b1a50ee713b65928adb22269e4", size = 342283, upload-time = "2025-08-27T12:14:42.699Z" }, + { url = "https://files.pythonhosted.org/packages/1c/15/2a7c619b3c2272ea9feb9ade67a45c40b3eeb500d503ad4c28c395dc51b4/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9e960fc78fecd1100539f14132425e1d5fe44ecb9239f8f27f079962021523e", size = 380320, upload-time = "2025-08-27T12:14:44.157Z" }, + { url = "https://files.pythonhosted.org/packages/a2/7d/4c6d243ba4a3057e994bb5bedd01b5c963c12fe38dde707a52acdb3849e7/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62f85b665cedab1a503747617393573995dac4600ff51869d69ad2f39eb5e817", size = 391760, upload-time = "2025-08-27T12:14:45.845Z" }, + { url = "https://files.pythonhosted.org/packages/b4/71/b19401a909b83bcd67f90221330bc1ef11bc486fe4e04c24388d28a618ae/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fed467af29776f6556250c9ed85ea5a4dd121ab56a5f8b206e3e7a4c551e48ec", size = 522476, upload-time = "2025-08-27T12:14:47.364Z" }, + { url = "https://files.pythonhosted.org/packages/e4/44/1a3b9715c0455d2e2f0f6df5ee6d6f5afdc423d0773a8a682ed2b43c566c/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2729615f9d430af0ae6b36cf042cb55c0936408d543fb691e1a9e36648fd35a", size = 403418, upload-time = "2025-08-27T12:14:49.991Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4b/fb6c4f14984eb56673bc868a66536f53417ddb13ed44b391998100a06a96/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b207d881a9aef7ba753d69c123a35d96ca7cb808056998f6b9e8747321f03b8", size = 384771, upload-time = "2025-08-27T12:14:52.159Z" }, + { url = "https://files.pythonhosted.org/packages/c0/56/d5265d2d28b7420d7b4d4d85cad8ef891760f5135102e60d5c970b976e41/rpds_py-0.27.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:639fd5efec029f99b79ae47e5d7e00ad8a773da899b6309f6786ecaf22948c48", size = 400022, upload-time = "2025-08-27T12:14:53.859Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e9/9f5fc70164a569bdd6ed9046486c3568d6926e3a49bdefeeccfb18655875/rpds_py-0.27.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fecc80cb2a90e28af8a9b366edacf33d7a91cbfe4c2c4544ea1246e949cfebeb", size = 416787, upload-time = "2025-08-27T12:14:55.673Z" }, + { url = "https://files.pythonhosted.org/packages/d4/64/56dd03430ba491db943a81dcdef115a985aac5f44f565cd39a00c766d45c/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42a89282d711711d0a62d6f57d81aa43a1368686c45bc1c46b7f079d55692734", size = 557538, upload-time = "2025-08-27T12:14:57.245Z" }, + { url = "https://files.pythonhosted.org/packages/3f/36/92cc885a3129993b1d963a2a42ecf64e6a8e129d2c7cc980dbeba84e55fb/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:cf9931f14223de59551ab9d38ed18d92f14f055a5f78c1d8ad6493f735021bbb", size = 588512, upload-time = "2025-08-27T12:14:58.728Z" }, + { url = "https://files.pythonhosted.org/packages/dd/10/6b283707780a81919f71625351182b4f98932ac89a09023cb61865136244/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f39f58a27cc6e59f432b568ed8429c7e1641324fbe38131de852cd77b2d534b0", size = 555813, upload-time = "2025-08-27T12:15:00.334Z" }, + { url = "https://files.pythonhosted.org/packages/04/2e/30b5ea18c01379da6272a92825dd7e53dc9d15c88a19e97932d35d430ef7/rpds_py-0.27.1-cp314-cp314t-win32.whl", hash = "sha256:d5fa0ee122dc09e23607a28e6d7b150da16c662e66409bbe85230e4c85bb528a", size = 217385, upload-time = "2025-08-27T12:15:01.937Z" }, + { url = "https://files.pythonhosted.org/packages/32/7d/97119da51cb1dd3f2f3c0805f155a3aa4a95fa44fe7d78ae15e69edf4f34/rpds_py-0.27.1-cp314-cp314t-win_amd64.whl", hash = "sha256:6567d2bb951e21232c2f660c24cf3470bb96de56cdcb3f071a83feeaff8a2772", size = 230097, upload-time = "2025-08-27T12:15:03.961Z" }, + { url = "https://files.pythonhosted.org/packages/7f/6c/252e83e1ce7583c81f26d1d884b2074d40a13977e1b6c9c50bbf9a7f1f5a/rpds_py-0.27.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c918c65ec2e42c2a78d19f18c553d77319119bf43aa9e2edf7fb78d624355527", size = 372140, upload-time = "2025-08-27T12:15:05.441Z" }, + { url = "https://files.pythonhosted.org/packages/9d/71/949c195d927c5aeb0d0629d329a20de43a64c423a6aa53836290609ef7ec/rpds_py-0.27.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1fea2b1a922c47c51fd07d656324531adc787e415c8b116530a1d29c0516c62d", size = 354086, upload-time = "2025-08-27T12:15:07.404Z" }, + { url = "https://files.pythonhosted.org/packages/9f/02/e43e332ad8ce4f6c4342d151a471a7f2900ed1d76901da62eb3762663a71/rpds_py-0.27.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbf94c58e8e0cd6b6f38d8de67acae41b3a515c26169366ab58bdca4a6883bb8", size = 382117, upload-time = "2025-08-27T12:15:09.275Z" }, + { url = "https://files.pythonhosted.org/packages/d0/05/b0fdeb5b577197ad72812bbdfb72f9a08fa1e64539cc3940b1b781cd3596/rpds_py-0.27.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c2a8fed130ce946d5c585eddc7c8eeef0051f58ac80a8ee43bd17835c144c2cc", size = 394520, upload-time = "2025-08-27T12:15:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/67/1f/4cfef98b2349a7585181e99294fa2a13f0af06902048a5d70f431a66d0b9/rpds_py-0.27.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:037a2361db72ee98d829bc2c5b7cc55598ae0a5e0ec1823a56ea99374cfd73c1", size = 522657, upload-time = "2025-08-27T12:15:12.613Z" }, + { url = "https://files.pythonhosted.org/packages/44/55/ccf37ddc4c6dce7437b335088b5ca18da864b334890e2fe9aa6ddc3f79a9/rpds_py-0.27.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5281ed1cc1d49882f9997981c88df1a22e140ab41df19071222f7e5fc4e72125", size = 402967, upload-time = "2025-08-27T12:15:14.113Z" }, + { url = "https://files.pythonhosted.org/packages/74/e5/5903f92e41e293b07707d5bf00ef39a0eb2af7190aff4beaf581a6591510/rpds_py-0.27.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fd50659a069c15eef8aa3d64bbef0d69fd27bb4a50c9ab4f17f83a16cbf8905", size = 384372, upload-time = "2025-08-27T12:15:15.842Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e3/fbb409e18aeefc01e49f5922ac63d2d914328430e295c12183ce56ebf76b/rpds_py-0.27.1-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:c4b676c4ae3921649a15d28ed10025548e9b561ded473aa413af749503c6737e", size = 401264, upload-time = "2025-08-27T12:15:17.388Z" }, + { url = "https://files.pythonhosted.org/packages/55/79/529ad07794e05cb0f38e2f965fc5bb20853d523976719400acecc447ec9d/rpds_py-0.27.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:079bc583a26db831a985c5257797b2b5d3affb0386e7ff886256762f82113b5e", size = 418691, upload-time = "2025-08-27T12:15:19.144Z" }, + { url = "https://files.pythonhosted.org/packages/33/39/6554a7fd6d9906fda2521c6d52f5d723dca123529fb719a5b5e074c15e01/rpds_py-0.27.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4e44099bd522cba71a2c6b97f68e19f40e7d85399de899d66cdb67b32d7cb786", size = 558989, upload-time = "2025-08-27T12:15:21.087Z" }, + { url = "https://files.pythonhosted.org/packages/19/b2/76fa15173b6f9f445e5ef15120871b945fb8dd9044b6b8c7abe87e938416/rpds_py-0.27.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e202e6d4188e53c6661af813b46c37ca2c45e497fc558bacc1a7630ec2695aec", size = 589835, upload-time = "2025-08-27T12:15:22.696Z" }, + { url = "https://files.pythonhosted.org/packages/ee/9e/5560a4b39bab780405bed8a88ee85b30178061d189558a86003548dea045/rpds_py-0.27.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:f41f814b8eaa48768d1bb551591f6ba45f87ac76899453e8ccd41dba1289b04b", size = 555227, upload-time = "2025-08-27T12:15:24.278Z" }, + { url = "https://files.pythonhosted.org/packages/52/d7/cd9c36215111aa65724c132bf709c6f35175973e90b32115dedc4ced09cb/rpds_py-0.27.1-cp39-cp39-win32.whl", hash = "sha256:9e71f5a087ead99563c11fdaceee83ee982fd39cf67601f4fd66cb386336ee52", size = 217899, upload-time = "2025-08-27T12:15:25.926Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e0/d75ab7b4dd8ba777f6b365adbdfc7614bbfe7c5f05703031dfa4b61c3d6c/rpds_py-0.27.1-cp39-cp39-win_amd64.whl", hash = "sha256:71108900c9c3c8590697244b9519017a400d9ba26a36c48381b3f64743a44aab", size = 228725, upload-time = "2025-08-27T12:15:27.398Z" }, + { url = "https://files.pythonhosted.org/packages/d5/63/b7cc415c345625d5e62f694ea356c58fb964861409008118f1245f8c3347/rpds_py-0.27.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7ba22cb9693df986033b91ae1d7a979bc399237d45fccf875b76f62bb9e52ddf", size = 371360, upload-time = "2025-08-27T12:15:29.218Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8c/12e1b24b560cf378b8ffbdb9dc73abd529e1adcfcf82727dfd29c4a7b88d/rpds_py-0.27.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5b640501be9288c77738b5492b3fd3abc4ba95c50c2e41273c8a1459f08298d3", size = 353933, upload-time = "2025-08-27T12:15:30.837Z" }, + { url = "https://files.pythonhosted.org/packages/9b/85/1bb2210c1f7a1b99e91fea486b9f0f894aa5da3a5ec7097cbad7dec6d40f/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb08b65b93e0c6dd70aac7f7890a9c0938d5ec71d5cb32d45cf844fb8ae47636", size = 382962, upload-time = "2025-08-27T12:15:32.348Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c9/a839b9f219cf80ed65f27a7f5ddbb2809c1b85c966020ae2dff490e0b18e/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d7ff07d696a7a38152ebdb8212ca9e5baab56656749f3d6004b34ab726b550b8", size = 394412, upload-time = "2025-08-27T12:15:33.839Z" }, + { url = "https://files.pythonhosted.org/packages/02/2d/b1d7f928b0b1f4fc2e0133e8051d199b01d7384875adc63b6ddadf3de7e5/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fb7c72262deae25366e3b6c0c0ba46007967aea15d1eea746e44ddba8ec58dcc", size = 523972, upload-time = "2025-08-27T12:15:35.377Z" }, + { url = "https://files.pythonhosted.org/packages/a9/af/2cbf56edd2d07716df1aec8a726b3159deb47cb5c27e1e42b71d705a7c2f/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b002cab05d6339716b03a4a3a2ce26737f6231d7b523f339fa061d53368c9d8", size = 403273, upload-time = "2025-08-27T12:15:37.051Z" }, + { url = "https://files.pythonhosted.org/packages/c0/93/425e32200158d44ff01da5d9612c3b6711fe69f606f06e3895511f17473b/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23f6b69d1c26c4704fec01311963a41d7de3ee0570a84ebde4d544e5a1859ffc", size = 385278, upload-time = "2025-08-27T12:15:38.571Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1a/1a04a915ecd0551bfa9e77b7672d1937b4b72a0fc204a17deef76001cfb2/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:530064db9146b247351f2a0250b8f00b289accea4596a033e94be2389977de71", size = 402084, upload-time = "2025-08-27T12:15:40.529Z" }, + { url = "https://files.pythonhosted.org/packages/51/f7/66585c0fe5714368b62951d2513b684e5215beaceab2c6629549ddb15036/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b90b0496570bd6b0321724a330d8b545827c4df2034b6ddfc5f5275f55da2ad", size = 419041, upload-time = "2025-08-27T12:15:42.191Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7e/83a508f6b8e219bba2d4af077c35ba0e0cdd35a751a3be6a7cba5a55ad71/rpds_py-0.27.1-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:879b0e14a2da6a1102a3fc8af580fc1ead37e6d6692a781bd8c83da37429b5ab", size = 560084, upload-time = "2025-08-27T12:15:43.839Z" }, + { url = "https://files.pythonhosted.org/packages/66/66/bb945683b958a1b19eb0fe715594630d0f36396ebdef4d9b89c2fa09aa56/rpds_py-0.27.1-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:0d807710df3b5faa66c731afa162ea29717ab3be17bdc15f90f2d9f183da4059", size = 590115, upload-time = "2025-08-27T12:15:46.647Z" }, + { url = "https://files.pythonhosted.org/packages/12/00/ccfaafaf7db7e7adace915e5c2f2c2410e16402561801e9c7f96683002d3/rpds_py-0.27.1-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:3adc388fc3afb6540aec081fa59e6e0d3908722771aa1e37ffe22b220a436f0b", size = 556561, upload-time = "2025-08-27T12:15:48.219Z" }, + { url = "https://files.pythonhosted.org/packages/e1/b7/92b6ed9aad103bfe1c45df98453dfae40969eef2cb6c6239c58d7e96f1b3/rpds_py-0.27.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c796c0c1cc68cb08b0284db4229f5af76168172670c74908fdbd4b7d7f515819", size = 229125, upload-time = "2025-08-27T12:15:49.956Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ed/e1fba02de17f4f76318b834425257c8ea297e415e12c68b4361f63e8ae92/rpds_py-0.27.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:cdfe4bb2f9fe7458b7453ad3c33e726d6d1c7c0a72960bcc23800d77384e42df", size = 371402, upload-time = "2025-08-27T12:15:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/af/7c/e16b959b316048b55585a697e94add55a4ae0d984434d279ea83442e460d/rpds_py-0.27.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:8fabb8fd848a5f75a2324e4a84501ee3a5e3c78d8603f83475441866e60b94a3", size = 354084, upload-time = "2025-08-27T12:15:53.219Z" }, + { url = "https://files.pythonhosted.org/packages/de/c1/ade645f55de76799fdd08682d51ae6724cb46f318573f18be49b1e040428/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eda8719d598f2f7f3e0f885cba8646644b55a187762bec091fa14a2b819746a9", size = 383090, upload-time = "2025-08-27T12:15:55.158Z" }, + { url = "https://files.pythonhosted.org/packages/1f/27/89070ca9b856e52960da1472efcb6c20ba27cfe902f4f23ed095b9cfc61d/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c64d07e95606ec402a0a1c511fe003873fa6af630bda59bac77fac8b4318ebc", size = 394519, upload-time = "2025-08-27T12:15:57.238Z" }, + { url = "https://files.pythonhosted.org/packages/b3/28/be120586874ef906aa5aeeae95ae8df4184bc757e5b6bd1c729ccff45ed5/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:93a2ed40de81bcff59aabebb626562d48332f3d028ca2036f1d23cbb52750be4", size = 523817, upload-time = "2025-08-27T12:15:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/70cc197bc11cfcde02a86f36ac1eed15c56667c2ebddbdb76a47e90306da/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:387ce8c44ae94e0ec50532d9cb0edce17311024c9794eb196b90e1058aadeb66", size = 403240, upload-time = "2025-08-27T12:16:00.923Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/46936cca449f7f518f2f4996e0e8344db4b57e2081e752441154089d2a5f/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaf94f812c95b5e60ebaf8bfb1898a7d7cb9c1af5744d4a67fa47796e0465d4e", size = 385194, upload-time = "2025-08-27T12:16:02.802Z" }, + { url = "https://files.pythonhosted.org/packages/e1/62/29c0d3e5125c3270b51415af7cbff1ec587379c84f55a5761cc9efa8cd06/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:4848ca84d6ded9b58e474dfdbad4b8bfb450344c0551ddc8d958bf4b36aa837c", size = 402086, upload-time = "2025-08-27T12:16:04.806Z" }, + { url = "https://files.pythonhosted.org/packages/8f/66/03e1087679227785474466fdd04157fb793b3b76e3fcf01cbf4c693c1949/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2bde09cbcf2248b73c7c323be49b280180ff39fadcfe04e7b6f54a678d02a7cf", size = 419272, upload-time = "2025-08-27T12:16:06.471Z" }, + { url = "https://files.pythonhosted.org/packages/6a/24/e3e72d265121e00b063aef3e3501e5b2473cf1b23511d56e529531acf01e/rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:94c44ee01fd21c9058f124d2d4f0c9dc7634bec93cd4b38eefc385dabe71acbf", size = 560003, upload-time = "2025-08-27T12:16:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/f5a344c534214cc2d41118c0699fffbdc2c1bc7046f2a2b9609765ab9c92/rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:df8b74962e35c9249425d90144e721eed198e6555a0e22a563d29fe4486b51f6", size = 590482, upload-time = "2025-08-27T12:16:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/ce/08/4349bdd5c64d9d193c360aa9db89adeee6f6682ab8825dca0a3f535f434f/rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:dc23e6820e3b40847e2f4a7726462ba0cf53089512abe9ee16318c366494c17a", size = 556523, upload-time = "2025-08-27T12:16:12.188Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ea/5463cd5048a7a2fcdae308b6e96432802132c141bfb9420260142632a0f1/rpds_py-0.27.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:aa8933159edc50be265ed22b401125c9eebff3171f570258854dbce3ecd55475", size = 371778, upload-time = "2025-08-27T12:16:13.851Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c8/f38c099db07f5114029c1467649d308543906933eebbc226d4527a5f4693/rpds_py-0.27.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:a50431bf02583e21bf273c71b89d710e7a710ad5e39c725b14e685610555926f", size = 354394, upload-time = "2025-08-27T12:16:15.609Z" }, + { url = "https://files.pythonhosted.org/packages/7d/79/b76f97704d9dd8ddbd76fed4c4048153a847c5d6003afe20a6b5c3339065/rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78af06ddc7fe5cc0e967085a9115accee665fb912c22a3f54bad70cc65b05fe6", size = 382348, upload-time = "2025-08-27T12:16:17.251Z" }, + { url = "https://files.pythonhosted.org/packages/8a/3f/ef23d3c1be1b837b648a3016d5bbe7cfe711422ad110b4081c0a90ef5a53/rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:70d0738ef8fee13c003b100c2fbd667ec4f133468109b3472d249231108283a3", size = 394159, upload-time = "2025-08-27T12:16:19.251Z" }, + { url = "https://files.pythonhosted.org/packages/74/8a/9e62693af1a34fd28b1a190d463d12407bd7cf561748cb4745845d9548d3/rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2f6fd8a1cea5bbe599b6e78a6e5ee08db434fc8ffea51ff201c8765679698b3", size = 522775, upload-time = "2025-08-27T12:16:20.929Z" }, + { url = "https://files.pythonhosted.org/packages/36/0d/8d5bb122bf7a60976b54c5c99a739a3819f49f02d69df3ea2ca2aff47d5c/rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8177002868d1426305bb5de1e138161c2ec9eb2d939be38291d7c431c4712df8", size = 402633, upload-time = "2025-08-27T12:16:22.548Z" }, + { url = "https://files.pythonhosted.org/packages/0f/0e/237948c1f425e23e0cf5a566d702652a6e55c6f8fbd332a1792eb7043daf/rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:008b839781d6c9bf3b6a8984d1d8e56f0ec46dc56df61fd669c49b58ae800400", size = 384867, upload-time = "2025-08-27T12:16:24.29Z" }, + { url = "https://files.pythonhosted.org/packages/d6/0a/da0813efcd998d260cbe876d97f55b0f469ada8ba9cbc47490a132554540/rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:a55b9132bb1ade6c734ddd2759c8dc132aa63687d259e725221f106b83a0e485", size = 401791, upload-time = "2025-08-27T12:16:25.954Z" }, + { url = "https://files.pythonhosted.org/packages/51/78/c6c9e8a8aaca416a6f0d1b6b4a6ee35b88fe2c5401d02235d0a056eceed2/rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a46fdec0083a26415f11d5f236b79fa1291c32aaa4a17684d82f7017a1f818b1", size = 419525, upload-time = "2025-08-27T12:16:27.659Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/5af37e1d71487cf6d56dd1420dc7e0c2732c1b6ff612aa7a88374061c0a8/rpds_py-0.27.1-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:8a63b640a7845f2bdd232eb0d0a4a2dd939bcdd6c57e6bb134526487f3160ec5", size = 559255, upload-time = "2025-08-27T12:16:29.343Z" }, + { url = "https://files.pythonhosted.org/packages/40/7f/8b7b136069ef7ac3960eda25d832639bdb163018a34c960ed042dd1707c8/rpds_py-0.27.1-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:7e32721e5d4922deaaf963469d795d5bde6093207c52fec719bd22e5d1bedbc4", size = 590384, upload-time = "2025-08-27T12:16:31.005Z" }, + { url = "https://files.pythonhosted.org/packages/d8/06/c316d3f6ff03f43ccb0eba7de61376f8ec4ea850067dddfafe98274ae13c/rpds_py-0.27.1-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:2c426b99a068601b5f4623573df7a7c3d72e87533a2dd2253353a03e7502566c", size = 555959, upload-time = "2025-08-27T12:16:32.73Z" }, + { url = "https://files.pythonhosted.org/packages/60/94/384cf54c430b9dac742bbd2ec26c23feb78ded0d43d6d78563a281aec017/rpds_py-0.27.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:4fc9b7fe29478824361ead6e14e4f5aed570d477e06088826537e202d25fe859", size = 228784, upload-time = "2025-08-27T12:16:34.428Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, + { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, + { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, + { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, +] + [[package]] name = "ruamel-yaml" version = "0.19.1" @@ -2701,6 +3213,8 @@ dev = [ { name = "cryptography", version = "47.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.9'" }, { name = "cryptography", version = "50.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.9'" }, { name = "cython" }, + { name = "jsonschema", version = "4.25.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "jsonschema", version = "4.26.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, @@ -2733,6 +3247,7 @@ dev = [ { name = "coverage", extras = ["toml"], specifier = ">=7.6" }, { name = "cryptography", specifier = ">=42.0" }, { name = "cython", specifier = ">=3.2" }, + { name = "jsonschema", specifier = ">=4.18" }, { name = "numpy" }, { name = "objgraph" }, { name = "packaging", specifier = ">=25.0" }, From deb4c5f889c891a21539c7f0ca26020ed9d0c217 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Tue, 25 Aug 2026 14:12:53 +0200 Subject: [PATCH 161/200] DRIVER-379: Give the config reporter the cluster and the Scylla flag The configuration groups that follow need the cluster whose settings they describe, and one of them needs to know whether the node is a ScyllaDB one. This puts both in place without changing what is reported. The cluster is held weakly: it owns the reporter and hands it to every connection it opens, so a strong reference here would keep it alive for as long as any connection holds a reporter. Finding it gone is a shutdown race rather than a misconfiguration, so the option is left out at debug level. is_scylla is passed in rather than discovered, because the connection already knows -- _handle_options_response parses SUPPORTED into self.features before it builds these options -- and the predicate is the one the driver itself keys ScyllaDB-only behaviour off, so the report describes what the driver will do rather than only what it was configured to do. It is required: the sole caller always knows, and a default would let a wrong answer through quietly. The connection tests move to a stub report, so that what they establish -- which connections carry the report, and that an application cannot supply its own -- does not break on every configuration group that lands next. Co-Authored-By: Claude Opus 5 (1M context) --- cassandra/cluster.py | 2 +- cassandra/connection.py | 8 +++- cassandra/driver_config.py | 41 ++++++++++++++---- tests/unit/test_cluster.py | 12 +++++- tests/unit/test_connection.py | 17 ++++---- tests/unit/test_driver_config.py | 74 ++++++++++++++++++++++++++------ tests/unit/utils.py | 38 ++++++++++++++-- 7 files changed, 154 insertions(+), 38 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 7260bd08b6..bffe8695c2 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -1520,7 +1520,7 @@ def __init__(self, # Built whatever the flag says, so that the flag is the only thing that # decides whether a connection reports: see _make_connection_kwargs. The # reporter holds no state, so an unused one costs nothing. - self._driver_config_reporter = DriverConfigReporter() + self._driver_config_reporter = DriverConfigReporter(self) self.control_connection = ControlConnection( self, self.control_connection_timeout, diff --git a/cassandra/connection.py b/cassandra/connection.py index 9eb0d762f5..b4ea59b23c 100644 --- a/cassandra/connection.py +++ b/cassandra/connection.py @@ -1605,7 +1605,13 @@ def _handle_options_response(self, options_response): # only the control connection reports it. A reporter left as None means # the cluster has configuration reporting disabled. if self.is_control_connection and self._driver_config_reporter is not None: - self._driver_config_reporter.add_startup_options(options) + # Whether this is a ScyllaDB node is already known: the features + # above were parsed from the SUPPORTED response, and sharding info + # is what the driver itself keys ScyllaDB-only behaviour off (see + # ControlConnection._try_connect), so the report describes what the + # driver will actually do rather than only what it was configured to. + self._driver_config_reporter.add_startup_options( + options, is_scylla=self.features.sharding_info is not None) if self.cql_version: if self.cql_version not in supported_cql_versions: diff --git a/cassandra/driver_config.py b/cassandra/driver_config.py index af5bf276f2..4734012cfd 100644 --- a/cassandra/driver_config.py +++ b/cassandra/driver_config.py @@ -20,6 +20,7 @@ import json import logging +import weakref log = logging.getLogger(__name__) @@ -81,10 +82,21 @@ class DriverConfigReporter: :meth:`cassandra.connection.Connection._handle_options_response`, not here. """ - def add_startup_options(self, options): + def __init__(self, cluster): + # Weak, because the cluster owns the reporter and hands it to every + # connection it opens: a strong reference here would run back through + # each of them and keep the cluster alive for as long as any connection + # holds a reporter. + self._cluster = weakref.ref(cluster) + + def add_startup_options(self, options, is_scylla): """ Adds the configuration report to the ``STARTUP`` options being built. + `is_scylla` says whether the node this connection is being established + to is a ScyllaDB one, which decides the keys that describe behaviour the + driver only has against ScyllaDB. + Reporting is best effort: this runs while a connection is being established, so a report that cannot be built or does not fit is logged and left out rather than allowed to fail the connection. @@ -96,7 +108,16 @@ def add_startup_options(self, options): left in ``options`` either. """ try: - report = self._build_report() + cluster = self._cluster() + if cluster is None: + # The application dropped its Cluster while this connection was + # being established. Nothing is wrong and nothing is worth + # warning about: the connection is on its way out too. + log.debug("The cluster is gone, its configuration will not be " + "reported on this connection") + return + + report = self._build_report(cluster, is_scylla) length = len(report.encode('utf8')) if length > MAX_DRIVER_CONFIG_LENGTH: log.warning("The driver configuration report is %d bytes long, which exceeds the " @@ -109,22 +130,24 @@ def add_startup_options(self, options): log.warning("Unable to build the driver configuration report, " "it will not be reported to the cluster", exc_info=True) - def _build_report(self): + def _build_report(self, cluster, is_scylla): """ - Returns the JSON configuration report. + Returns the JSON configuration report of `cluster`. It is built for every control connection rather than cached, so that it - always describes the configuration as it is at that point in time. Later - configuration groups may well describe state that is only known once the - cluster has been contacted. + always describes the configuration as it is at that point in time. Some + of what it describes is only known once a connection has got this far: + `is_scylla` comes out of the ``SUPPORTED`` response, and a datacenter + the driver inferred rather than was given is not known until the first + host comes up. """ report = {'version': DRIVER_CONFIG_SCHEMA_VERSION} - self._populate_report(report) + self._populate_report(report, cluster, is_scylla) # Separators without whitespace: the report is a wire value bounded by # MAX_DRIVER_CONFIG_LENGTH, not something meant to be read as it is. return json.dumps(report, separators=(',', ':')) - def _populate_report(self, report): + def _populate_report(self, report, cluster, is_scylla): """ Extension point for adding the configuration groups themselves to the report. Empty for now. diff --git a/tests/unit/test_cluster.py b/tests/unit/test_cluster.py index 35dc354465..62e346016a 100644 --- a/tests/unit/test_cluster.py +++ b/tests/unit/test_cluster.py @@ -319,6 +319,16 @@ def test_connection_factory_reports_the_session_id_and_the_configuration(self): assert factory.call_args.kwargs['session_id'] == cluster.session_id assert isinstance(factory.call_args.kwargs['driver_config_reporter'], DriverConfigReporter) + def test_the_reporter_describes_the_cluster_that_owns_it(self): + """ + The reporter reads its configuration off the cluster when a connection + asks for the report, so it has to be pointed at the one that owns it. + """ + cluster = Cluster() + self.addCleanup(cluster.shutdown) + + assert cluster._driver_config_reporter._cluster() is cluster + def test_driver_config_reporting_can_be_toggled_after_construction(self): """ The flag is a plain published attribute, so it is read when a connection @@ -375,7 +385,7 @@ def test_connection_factory_ignores_a_caller_supplied_session_id_and_reporter(se cluster = Cluster(driver_config_reporting_enabled=False) self.addCleanup(cluster.shutdown) cluster.connection_factory(endpoint, session_id=uuid.uuid4(), - driver_config_reporter=DriverConfigReporter()) + driver_config_reporter=DriverConfigReporter(cluster)) assert factory.call_args.kwargs['session_id'] == cluster.session_id assert factory.call_args.kwargs['driver_config_reporter'] is None diff --git a/tests/unit/test_connection.py b/tests/unit/test_connection.py index adbe0833cc..fcea10dfaf 100644 --- a/tests/unit/test_connection.py +++ b/tests/unit/test_connection.py @@ -26,14 +26,13 @@ locally_supported_compressions, ConnectionHeartbeat, HeartbeatFuture, _Frame, Timer, TimerManager, ConnectionException, ConnectionShutdown, DefaultEndPoint, ShardAwarePortGenerator, DRIVER_NAME, DRIVER_VERSION) -from cassandra.driver_config import (DriverConfigReporter, DRIVER_CONFIG_OPTION, - DRIVER_CONFIG_SCHEMA_VERSION, SESSION_ID_OPTION) +from cassandra.driver_config import DRIVER_CONFIG_OPTION, SESSION_ID_OPTION from cassandra.marshal import uint8_pack, uint32_pack, int32_pack from cassandra.protocol import (write_stringmultimap, write_int, write_string, read_stringmap, SupportedMessage, ProtocolHandler, ResultMessage, RESULT_KIND_SET_KEYSPACE) -from tests.unit.utils import ThrowingReporter +from tests.unit.utils import StubReporter, ThrowingReporter from tests.util import wait_until, assertRegex import pytest @@ -564,7 +563,7 @@ def add_startup_options(self, options): ABSENT = object() cases = [ ("a pool connection reports no configuration at all", - {'driver_config_reporter': DriverConfigReporter()}, + {'driver_config_reporter': StubReporter()}, ABSENT), ("nor does a control connection with reporting disabled", {'is_control_connection': True}, @@ -573,8 +572,8 @@ def add_startup_options(self, options): {'is_control_connection': True, 'driver_config_reporter': ThrowingReporter()}, ABSENT), ("the driver's own report wins where there is one", - {'is_control_connection': True, 'driver_config_reporter': DriverConfigReporter()}, - '{"version":%d}' % DRIVER_CONFIG_SCHEMA_VERSION), + {'is_control_connection': True, 'driver_config_reporter': StubReporter()}, + StubReporter.REPORT), ] for description, kwargs, expected in cases: @@ -662,9 +661,9 @@ def add_startup_options(self, options): def test_driver_config_is_reported_on_the_control_connection(self): options = self.startup_options(is_control_connection=True, - driver_config_reporter=DriverConfigReporter()) + driver_config_reporter=StubReporter()) - assert options[DRIVER_CONFIG_OPTION] == '{"version":%d}' % DRIVER_CONFIG_SCHEMA_VERSION + assert options[DRIVER_CONFIG_OPTION] == StubReporter.REPORT def test_driver_config_is_not_reported_on_a_regular_connection(self): """ @@ -673,7 +672,7 @@ def test_driver_config_is_not_reported_on_a_regular_connection(self): session id that ties them to it. """ options = self.startup_options(session_id=self.SESSION_ID, - driver_config_reporter=DriverConfigReporter()) + driver_config_reporter=StubReporter()) assert SESSION_ID_OPTION in options assert DRIVER_CONFIG_OPTION not in options diff --git a/tests/unit/test_driver_config.py b/tests/unit/test_driver_config.py index e9d94c92fc..079356c01f 100644 --- a/tests/unit/test_driver_config.py +++ b/tests/unit/test_driver_config.py @@ -12,38 +12,56 @@ # See the License for the specific language governing permissions and # limitations under the License. +import gc import json import unittest +from unittest import mock +from unittest.mock import Mock from cassandra.driver_config import (DriverConfigReporter, DRIVER_CONFIG_OPTION, DRIVER_CONFIG_SCHEMA_VERSION, MAX_DRIVER_CONFIG_LENGTH) -from tests.unit.utils import ThrowingReporter +from tests.unit.utils import _ClusterlessReporter, ThrowingReporter -class OversizedReporter(DriverConfigReporter): +class OversizedReporter(_ClusterlessReporter): """ Produces a report one byte past the limit. The schema-only report built by :class:`.DriverConfigReporter` cannot reach the limit on its own, so the guard is only reachable through a subclass. """ - def _build_report(self): + def _build_report(self, cluster, is_scylla): return 'a' * (MAX_DRIVER_CONFIG_LENGTH + 1) -class MistypedReporter(DriverConfigReporter): +class MistypedReporter(_ClusterlessReporter): """ Returns something that is not a string, the mistake the ``_populate_report`` extension point invites once it describes more than the schema version. """ - def _build_report(self): + def _build_report(self, cluster, is_scylla): return None +def reporter(cluster=None): + """ + A reporter over `cluster`, defaulting to one whose configuration is never + read because nothing populates the report yet. + + The cluster is kept alive for as long as the reporter is: it is held weakly, + so a temporary would be collected before the report is built and every test + here would silently exercise the cluster-is-gone path instead. + """ + cluster = cluster if cluster is not None else Mock() + r = DriverConfigReporter(cluster) + r._strong_cluster = cluster + return r + + class DriverConfigReporterTest(unittest.TestCase): def test_reports_the_schema_version(self): options = {} - DriverConfigReporter().add_startup_options(options) + reporter().add_startup_options(options, is_scylla=True) assert json.loads(options[DRIVER_CONFIG_OPTION]) == {'version': DRIVER_CONFIG_SCHEMA_VERSION} @@ -54,7 +72,7 @@ def test_report_is_compact_json(self): """ options = {} - DriverConfigReporter().add_startup_options(options) + reporter().add_startup_options(options, is_scylla=True) assert options[DRIVER_CONFIG_OPTION] == '{"version":%d}' % DRIVER_CONFIG_SCHEMA_VERSION @@ -66,7 +84,7 @@ def test_report_fits_within_the_length_limit(self): """ options = {} - DriverConfigReporter().add_startup_options(options) + reporter().add_startup_options(options, is_scylla=True) assert DRIVER_CONFIG_OPTION in options, \ "the report was dropped, it must have exceeded the length limit" @@ -76,7 +94,7 @@ def test_report_fits_within_the_length_limit(self): def test_oversized_report_is_not_reported(self): options = {} - OversizedReporter().add_startup_options(options) + OversizedReporter().add_startup_options(options, is_scylla=True) assert DRIVER_CONFIG_OPTION not in options @@ -87,7 +105,7 @@ def test_failure_to_build_the_report_is_not_reported(self): """ options = {} - ThrowingReporter().add_startup_options(options) + ThrowingReporter().add_startup_options(options, is_scylla=True) assert DRIVER_CONFIG_OPTION not in options @@ -99,15 +117,43 @@ def test_a_report_that_is_not_a_string_is_not_reported(self): """ options = {} - MistypedReporter().add_startup_options(options) + MistypedReporter().add_startup_options(options, is_scylla=True) + + assert DRIVER_CONFIG_OPTION not in options + + def test_the_cluster_is_held_weakly(self): + """ + The cluster owns the reporter and hands it to every connection it opens, + so a strong reference here would run back through each of them and keep + the cluster alive for as long as any connection holds a reporter. + """ + cluster = Mock() + r = DriverConfigReporter(cluster) + assert r._cluster() is cluster + + del cluster + gc.collect() + assert r._cluster() is None + + def test_nothing_is_reported_once_the_cluster_is_gone(self): + """ + An application dropping its Cluster while a connection is being + established is a shutdown race, not a misconfiguration: the option is + left out and nothing is warned about. + """ + r = DriverConfigReporter(Mock()) + options = {} + + with mock.patch.object(r, '_cluster', return_value=None): + r.add_startup_options(options, is_scylla=True) assert DRIVER_CONFIG_OPTION not in options def test_other_options_are_left_alone(self): options = {'APPLICATION_NAME': 'app'} - OversizedReporter().add_startup_options(options) - MistypedReporter().add_startup_options(options) - DriverConfigReporter().add_startup_options(options) + OversizedReporter().add_startup_options(options, is_scylla=True) + MistypedReporter().add_startup_options(options, is_scylla=True) + reporter().add_startup_options(options, is_scylla=True) assert options['APPLICATION_NAME'] == 'app' diff --git a/tests/unit/utils.py b/tests/unit/utils.py index d843358225..85f9784b82 100644 --- a/tests/unit/utils.py +++ b/tests/unit/utils.py @@ -13,7 +13,7 @@ # limitations under the License. from functools import wraps -from unittest.mock import patch +from unittest.mock import Mock, patch from concurrent.futures import Future from cassandra.cluster import Session @@ -35,7 +35,23 @@ def wrapper(*args, **kwargs): return wrapper -class ThrowingReporter(DriverConfigReporter): +class _ClusterlessReporter(DriverConfigReporter): + """ + Base for the reporter doubles below, which override report building and so + never read the cluster. Supplying a Mock keeps them constructible without + one while leaving the weak reference the real reporter holds in place. + + That reference is why the Mock is also held strongly: a temporary would be + collected before the report is built, and the double would then drop its + report because the cluster was gone rather than for the reason it exists to + demonstrate -- which is a test that passes while proving nothing. + """ + def __init__(self, cluster=None): + self._strong_cluster = cluster if cluster is not None else Mock() + super().__init__(self._strong_cluster) + + +class ThrowingReporter(_ClusterlessReporter): """ A driver configuration reporter whose report cannot be built. @@ -44,5 +60,21 @@ class ThrowingReporter(DriverConfigReporter): guarantee that such a failure leaves the STARTUP frame otherwise intact instead of failing the connection. """ - def _build_report(self): + def _build_report(self, cluster, is_scylla): raise ValueError("simulated failure while building the report") + + +class StubReporter(_ClusterlessReporter): + """ + A driver configuration reporter with a fixed, recognisable report. + + The connection tests are about where the report goes -- which connections + carry it, and that an application cannot supply its own -- not about what is + in it. Asserting the real report's text there would tie those guarantees to + every configuration group that lands afterwards, and break them all at once + for a reason that has nothing to do with connections. + """ + REPORT = '{"stub-report":true}' + + def _build_report(self, cluster, is_scylla): + return self.REPORT From 6e2d236210aac46170e312c672ddbef3a7956d00 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Tue, 25 Aug 2026 14:12:53 +0200 Subject: [PATCH 162/200] DRIVER-379: Report the connection group What the driver does with a single connection: connect timeout, request capacity, shard-aware pooling, socket options, reconnection policy and TLS hostname verification. Three of the schema's optional groups are left out for want of anything to put in them -- this driver has no socket read or write timeout, and the heartbeat group is empty in this schema version, so idle_heartbeat_interval has nowhere to go -- worth raising for v2. orphaned is reported, since Connection.orphaned_threshold bounds them; the schema leaves the group out for a client where nothing does. Its max is one below that threshold and floored at zero: the count is tested after the id is added, so a threshold of one or less tolerates no orphan at all, and the schema's nonNegativeInteger has no room for what subtracting would otherwise give. Socket options are read from sockopts rather than off a live socket. That would be the effective value the schema asks for, but only some of this driver's six reactors expose a socket object -- asyncio holds a transport -- so the report would change shape with the reactor in use. The driver sets none of its own, so an option absent from sockopts is at the operating system's default, which for a fresh TCP socket is off. Cluster.sockopts is materialized at construction, because it now has two readers: the sockets the cluster opens, and this report. A one-shot iterable would leave whichever ran second with nothing at all. The reconnection policy is described by driving its schedule rather than by reading max_attempts, because the values that stop one are not one kind of thing: zero and a negative stop the loop on its first test, and a nan stops it because every comparison against one is false -- and reaches the constructor unchallenged, since `nan < 0` is false too. float('inf') passes all three of those tests and means the opposite. A schedule that yields nothing is the schema's null arm; reporting an exponential policy with max-attempts left out would say the opposite, since the schema reads an absent max-attempts as unlimited. The reported base-ms is min(base_delay, max_delay). _add_jitter clamps every delay with `min(max(base_delay, delay), max_delay)`, so max_delay wins when the two are the wrong way round -- which the constructor rejects, but both stay writable afterwards. Taking the minimum reports the delay the policy will actually start at, and keeps the schema's requirement that max-ms be at least base-ms true by construction: it is a cross-property invariant JSON Schema cannot express, so the producer is the only thing that can hold it. inf and nan reach every duration setting unchallenged -- nothing validates one, and a nan passes even the constructors that reject a negative, since every comparison against one is false -- and both then raise out of int(). That cost the whole report rather than the one key that could not be converted, so each duration now says what the driver does with such a value: the optional fields leave the key out, which is already how this report says no limit is imposed, and the required ones raise with a message naming the kind of value rather than an opaque OverflowError. A reconnection delay is the null arm. Both schedulers compare a deadline of time.time() + delay against the clock, so an infinite delay or a nan is never due and nothing is ever reconnected -- the same thing the null arm already says of a schedule that yields nothing. A negative infinity is the opposite and is not one of these: its deadline is already past, so the timer fires at once and it reports as the immediate delay it is. Policies dispatch on their exact type: a subclass of a built-in is a policy the driver knows nothing about, and describing it as its parent would put the parent's parameters against behaviour it does not have. A custom one is reported by name and nothing else, though the schema permits its public attributes too -- whatever it holds, an auth provider or a credential, would land in system.clients for anyone who can select from it, and there is no telling which attributes are safe. That also bounds the report, so no configuration can drive it past the size limit. Co-Authored-By: Claude Opus 5 (1M context) --- cassandra/cluster.py | 12 +- cassandra/driver_config.py | 618 ++++++++++++++++++- tests/unit/test_cluster.py | 30 + tests/unit/test_driver_config.py | 981 ++++++++++++++++++++++++++++++- 4 files changed, 1606 insertions(+), 35 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index bffe8695c2..57fcf46331 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -1468,7 +1468,17 @@ def __init__(self, self.ssl_options = ssl_options self.ssl_context = ssl_context - self.sockopts = sockopts + # Materialized once: these are applied to every socket the cluster opens + # and are read again to build the configuration report, so a one-shot + # iterable would leave whichever consumer ran second with nothing at all. + # Something that is not a sequence of options at all is kept as it was + # given, so that it still fails where it always did -- on the socket, at + # connect time -- rather than turning a constructor that used to build + # into one that raises. + try: + self.sockopts = list(sockopts) if sockopts is not None else None + except TypeError: + self.sockopts = sockopts self.cql_version = cql_version self.max_schema_agreement_wait = max_schema_agreement_wait self.control_connection_timeout = control_connection_timeout diff --git a/cassandra/driver_config.py b/cassandra/driver_config.py index 4734012cfd..5a62821884 100644 --- a/cassandra/driver_config.py +++ b/cassandra/driver_config.py @@ -20,7 +20,15 @@ import json import logging +import math +import operator +import socket +import struct import weakref +from itertools import repeat + +from cassandra.policies import (ConstantReconnectionPolicy, + ExponentialReconnectionPolicy) log = logging.getLogger(__name__) @@ -57,20 +65,492 @@ ``STARTUP`` options are serialized by :func:`cassandra.protocol.write_string`, which prefixes every value with a 16 bit length, so a longer value would fail to -pack and take the handshake down with it. The report is a handful of bytes for -now, but the configuration groups added later describe user supplied values, -such as the settings of custom policies, and can grow arbitrarily large. -Enforcing a limit here keeps "reporting must never prevent a connection from -being established" a property of this module rather than of the user's -configuration. - -32 KiB rather than the protocol's own 65535 byte ceiling: real world reports are -expected to stay well under a couple of kilobytes, so this leaves ample headroom -while remaining far short of the point where the value would stop protecting -anything. +pack and take the handshake down with it. + +Nothing in the report is user-supplied: a custom policy contributes its type name +and nothing else, see :func:`_custom_policy_report`. Its size is therefore a +function of the driver's own settings rather than of the configuration it +describes, and stays well under a couple of kilobytes. The limit is kept anyway, +so that "reporting must never prevent a connection from being established" stays +a property of this module -- a later group describing something unbounded would +otherwise make it a property of the user's configuration without anyone noticing. + +32 KiB rather than the protocol's own 65535 byte ceiling: that leaves ample +headroom while remaining far short of the point where the value would stop +protecting anything. """ +def _finite(seconds): + """ + Whether `seconds` is a duration that can be converted to a whole number of + milliseconds at all. + + ``float('inf')`` and ``float('nan')`` reach every duration setting the + driver has, unchallenged: none of them validates its argument, and a nan + passes even the constructors that reject a negative, since every comparison + against one is false. Both then raise out of :func:`int` -- an OverflowError + and a ValueError respectively -- which is a failure the callers here have to + take a view on rather than let escape, since it would cost the whole report + and not just the key that could not be converted. + + A value that is not a number at all is a different failure and is left to + raise: reporting it as unbounded would answer a misconfigured duration with + a key quietly left out, where the driver itself will not get as far as using + it -- socket.settimeout and timedelta both reject one. + """ + return math.isfinite(seconds) + + +def _never_comes_due(delay): + """ + Whether a timer scheduled `delay` seconds out will never fire. + + Both schedulers compare a deadline of ``time.time() + delay`` against the + clock -- _Scheduler with `run_at <= time.time()`, Timer with `time_now >= + self.end`. An infinite delay puts that deadline beyond every reading the + clock will ever take, and a nan compares false against all of them, so + neither is ever due and the work is queued and never run. + + A negative infinity is the opposite and not one of these: its deadline is + already past, so the timer fires at the first opportunity, exactly as any + other negative delay does. + """ + try: + if _finite(delay): + return False + return not delay < 0 + except TypeError: + # Not a number at all, so it is not this that is wrong with it. The + # converters raise on it in their turn, as they did before. + return False + + +def _milliseconds(seconds): + """ + `seconds` in milliseconds, rounded to the nearest rather than truncated. + + Truncating loses a millisecond wherever the product lands just under its + integer, which binary floating point does often: 1.005 seconds multiplies + out to 1004.9999999999999, and reporting 1004 describes a timeout the + application did not set. 372 of the first 60000 whole milliseconds land that + way. + + Rounding does not disturb the sub-millisecond handling in the callers, since + everything below half a millisecond still arrives there as zero. + """ + return int(round(seconds * 1000)) + + +def _optional_ms(seconds): + """ + Milliseconds for a schema field of type ``positiveInteger``, or ``None`` + when the setting is unset or disabled and the key is to be left out. + + A configured duration below a millisecond reports as one rather than as + zero: it is a real setting, and zero is not a value the field can take. + + A duration that is not finite is left out too. These are all optional + fields, and absence is already how this report says the driver imposes no + limit here -- which is what an infinite one asks for, and the nearest thing + to the truth for a nan, whose timeout fires at no describable moment. + """ + if seconds is None or not _finite(seconds): + return None + ms = _milliseconds(seconds) + if ms < 1: + return 1 if seconds > 0 else None + return ms + + +def _required_ms(seconds): + """ + Milliseconds for a ``positiveInteger`` field the schema requires, so there + is no option of leaving it out: zero and below floor at one millisecond. + + A duration that never comes due raises rather than flooring with them. The + callers all recognise one before they get here and report the driver's + actual behaviour for it, so this is a backstop -- but it is the floor that + makes it worth having, since flooring would answer an infinite delay with + one millisecond, the furthest thing from it the field can hold. A negative + infinity is not one of those and floors as every other negative does: it + comes due at once, and one millisecond is the least the field can say. + """ + if _never_comes_due(seconds): + raise ValueError( + "a duration of %r cannot be reported: the field requires a whole " + "number of milliseconds and the schema has no way to say that a " + "duration is unbounded" % (seconds,)) + ms = _optional_ms(seconds) + return 1 if ms is None else ms + + +def _non_negative_ms(seconds): + """ + Milliseconds for a ``nonNegativeInteger`` field, where zero is a value in + its own right -- "do not wait", "reconnect immediately", "launch + immediately" -- and is reported as it is rather than treated as unset. + + Which is why a configured duration below a millisecond reports as one rather + than truncating to zero: zero here does not mean "very little", it means the + driver skips the wait altogether, and the two are not the same claim. The + driver draws that line in the same place -- ControlConnection. + _wait_for_schema_agreement bypasses agreement only when its timeout is zero + or less -- so a sub-millisecond wait is one the driver really does take. + + A duration that is not finite raises. Every caller whose field the schema + lets it leave out recognises one first, so what reaches here is + max_schema_agreement_wait, whose timeout-ms the schema requires: a wait of + no describable length has no conformant document to appear in, so dropping + the report is the outcome, and the message says why rather than leaving an + OverflowError under the generic warning. + """ + if seconds is None: + return 0 + if not _finite(seconds): + if _never_comes_due(seconds): + raise ValueError( + "a wait of %r cannot be reported: the field requires a whole " + "number of milliseconds and the schema has no way to say that a " + "wait is unbounded" % (seconds,)) + # A negative infinity, which is a negative like any other here: the + # driver skips the wait, and zero is what the schema calls that. + return 0 + ms = _milliseconds(seconds) + if ms < 1: + return 1 if seconds > 0 else 0 + return ms + + +_SOCKET_FLAGS = ( + ('tcp-no-delay', socket.IPPROTO_TCP, socket.TCP_NODELAY), + ('keep-alive', socket.SOL_SOCKET, socket.SO_KEEPALIVE), + ('reuse-address', socket.SOL_SOCKET, socket.SO_REUSEADDR), +) + +_SOCKET_BUFFERS = ( + ('receive-buffer', socket.SOL_SOCKET, socket.SO_RCVBUF), + ('send-buffer', socket.SOL_SOCKET, socket.SO_SNDBUF), +) + + +def _linger_report(value): + """ + The ``linger`` group from the value of an ``SO_LINGER`` socket option. + + Unlike the other options this one is a packed ``struct linger`` -- two C + ``int``s, on and interval -- since that is what + :meth:`socket.socket.setsockopt` takes, so it has to be unpacked to be + described. Every buffer type that method accepts is accepted here, and only + the leading two ``int``s are read, for the same reason as in + :func:`_socket_option_int`. Anything that does not unpack is left out rather + than guessed at: it is the user's to get wrong when the connection applies + it. + """ + if not isinstance(value, (bytes, bytearray, memoryview)): + return None + raw = bytes(value) + if len(raw) < struct.calcsize('ii'): + return None + try: + onoff, interval = struct.unpack_from('ii', raw) + except struct.error: + return None + if not onoff or interval < 0: + return None + return {'interval-s': interval} + + +def _socket_option_int(value): + """ + The integer a socket option carries, or ``None`` when it carries something + this module cannot read. + + :meth:`socket.socket.setsockopt` takes an integer option either as an + ``int`` or as a packed buffer, and the kernel reads the two the same way, so + this has to as well. A packed buffer is a non-empty ``bytes``, so handing one + straight to :func:`bool` makes every option look enabled -- including one + packed to zero precisely to turn it off. + + What is decoded is the C ``int`` at the front of the buffer, in native size + and byte order, because that is what the kernel reads for these options: it + takes the leading ``int`` and ignores whatever follows. Reading the buffer + as one wide integer instead would answer for bytes the option never had -- + ``struct.pack('ii', 0, 1)`` is accepted for ``TCP_NODELAY`` and leaves it + off, while the whole eight bytes come to a non-zero number. + + A buffer too short to hold an ``int`` is one ``setsockopt`` itself rejects, + so there is nothing to report for it. + """ + if isinstance(value, (bytes, bytearray, memoryview)): + raw = bytes(value) + if len(raw) < struct.calcsize('i'): + return None + return struct.unpack_from('i', raw)[0] + # Anything else has to be an integer setsockopt would take. __index__ is + # what CPython's takes -- a numpy integer sets an option just as a builtin + # one does -- and operator.index returns a builtin int, so a bool does not + # travel on into the report as one where a number is expected. + # + # Some interpreters are more permissive: PyPy's setsockopt accepts a Decimal + # and the kernel sets the option from it, and such a value is reported here + # as unset. Unlike the reconnection limit, which asks itertools.repeat + # directly, there is no way to ask setsockopt without a socket to ask it on, + # and a value it rejects fails the connection before there is any report to + # be wrong -- so the gap only shows on an interpreter that takes it. + try: + return operator.index(value) + except TypeError: + return None + + +def _socket_report(sockopts): + """ + The ``connection.socket`` group. + + The driver sets no socket options of its own: ``sockopts`` is what + :meth:`cassandra.connection.Connection._connect_socket` applies, and a + reactor that connects its own socket applies the same list, so an option that + is not in there is left at the operating system's default. The three flags + the schema requires are reported as off in that case, which is what every + platform this driver runs on defaults them to for a fresh TCP socket. + + With one exception, which is reported as off all the same. + :class:`~cassandra.io.asyncioreactor.AsyncioConnection` upgrades to TLS by + handing its socket to ``loop.create_connection(sock=..., ssl=...)``, and + asyncio's own transport sets TCP_NODELAY on it, so over TLS on that reactor + the flag is on however the application configured it. Left as configured + here, because this describes the driver's configuration: a flag that moves + with the reactor and the transport is not one the schema has a place to say. + """ + configured = {} + try: + entries = list(sockopts or ()) + except TypeError: + # Not a sequence of options at all. _connect_socket will fail on it, and + # there is nothing here to describe; the platform defaults below are + # what this can truthfully say. + entries = [] + + for opt in entries: + try: + level, name, value = opt + # Last one wins, as it does in the loop that applies them. Inside + # the guard with the unpacking, not after it: a level or name that + # cannot be a dict key -- a list, say -- raises here rather than + # there, and an entry the user got wrong is not this module's to + # fail the whole report over. + configured[(level, name)] = value + except (TypeError, ValueError): + # setsockopt also takes a (level, name, None, optlen) form, and an + # entry that is neither is the user's to get wrong at connect time, + # not this module's to report on. + continue + + report = {} + for key, level, name in _SOCKET_FLAGS: + report[key] = bool(_socket_option_int(configured.get((level, name)))) + for key, level, name in _SOCKET_BUFFERS: + size = _socket_option_int(configured.get((level, name))) + if size is not None and size > 0: + report[key] = {'size-bytes': size} + + linger = _linger_report(configured.get((socket.SOL_SOCKET, socket.SO_LINGER))) + if linger is not None: + report['linger'] = linger + return report + + +def _attempt_ceiling(limit): + """ + How many attempts a ``while i < limit`` loop makes, or ``None`` when `limit` + does not bound one. + + ``math.ceil`` rather than a check against particular numeric types: the loop + compares against anything an integer can be compared with, and this counts + anything that can say what its ceiling is. The result is coerced to a builtin + ``int`` so that no other numeric type reaches the report, where the schema + wants an integer. + """ + if limit is None: + return None + try: + attempts = int(math.ceil(limit)) + except (TypeError, ValueError, OverflowError): + return None + return attempts if attempts > 0 else None + + +def _constant_reconnection_attempts(max_attempts): + """ + How many reconnection attempts :class:`~.ConstantReconnectionPolicy` will + make, or ``None`` when it keeps trying or there is no count to report. + + ``new_schedule`` is ``repeat(delay, max_attempts)`` when `max_attempts` is + truthy and an unbounded ``repeat(delay)`` when it is not, so the falsy check + comes first: a zero there means unlimited, not none. + + Beyond that this asks :func:`itertools.repeat` itself, through the length it + reports, rather than testing the limit against a protocol. What ``repeat`` + accepts is not the same on every interpreter -- CPython wants ``__index__`` + and rejects a ``Decimal``, PyPy takes one and counts it -- so a driver + running on PyPy really does reconnect twice where the same configuration + raises on CPython. Asking the callee is the only way the report describes the + interpreter it is running on. + + A limit ``repeat`` will not take is a policy that raises when it reconnects, + and one too large for it to count is the same; neither has a count to report. + """ + if not max_attempts: + return None + try: + return operator.length_hint(repeat(None, max_attempts)) + except (TypeError, OverflowError): + return None + + +def _makes_no_reconnection_attempt(policy): + """ + Whether `policy`'s schedule yields nothing at all, so the driver never + reconnects. + + Asked of the schedule rather than worked out from max_attempts, because the + values that stop it are not one kind of thing. Zero and a negative stop the + loop on its first test; a nan stops it because every comparison against one + is false, and a nan reaches the constructor unchallenged since `nan < 0` is + false too. Meanwhile ``float('inf')`` passes all three of those tests and + means the opposite -- the loop never ends. + + Pulling one delay off a fresh schedule tells the three apart without + enumerating them. The generator holds no state on the policy, so asking + costs nothing and changes nothing. + + Asking runs the policy's own comparison, so it can raise where the + converters here deliberately do not: max_attempts stays writable after a + constructor that checked it, and `i < 'lots'` is a TypeError. That is a + schedule which yields nothing too, and for a reason the report has to carry + rather than let escape -- _ReconnectionHandler.start pulls the first delay + with a bare next(), so the same TypeError comes out there and no attempt is + ever scheduled. The alternative is worse than a lost group: _attempt_ceiling + cannot name such a limit either, so the report would come out exponential + with max-attempts absent, which this schema reads as unlimited. + """ + nothing = object() + try: + return next(iter(policy.new_schedule()), nothing) is nothing + except TypeError: + return True + + +def _reconnection_policy_report(policy): + """ + The ``connection.reconnection.policy`` value. + + Dispatched on the exact type: a subclass of a built-in policy is a policy + the driver knows nothing about, and describing it as its parent would put + that parent's parameters against behaviour it does not have. + """ + if policy is None: + # The schema's way of saying that no reconnection will be attempted. + return None + + if type(policy) is ExponentialReconnectionPolicy: + if _makes_no_reconnection_attempt(policy): + # The schedule yields nothing, so the driver never reconnects. That + # is the schema's null arm. Reporting an exponential policy with + # max-attempts left out would say the opposite, since the schema + # reads an absent max-attempts as unlimited -- and absent is what a + # limit no integer can name comes back as, which is right for + # float('inf') and wrong for every other one. + return None + if _never_comes_due(policy.base_delay) or _never_comes_due(policy.max_delay): + # A delay that never comes due is the null arm too, for the same + # reason a schedule that yields nothing is: the driver does not + # reconnect. _Scheduler tests `run_at <= time.time()` and Timer + # `time_now >= self.end`, and neither is ever true of an infinite + # delay or of a nan, so the attempt is queued and never run. + return None + if not policy.base_delay: + # The curve collapses. The schedule is base_delay * 2 ** i, so a + # base of zero stays zero however many attempts are made and however + # high max_delay is: the driver reconnects immediately, every time. + # That is the constant arm with a delay of zero. The exponential arm + # cannot say it -- its base is a positiveInteger -- and reporting it + # there would claim a delay that grows when none ever does. + report = {'type': 'constant', 'delay-ms': 0} + else: + # The initial delay is min(max_delay, base_delay), not base_delay: + # _add_jitter clamps every delay with + # `min(max(base_delay, delay), max_delay)`, so max_delay wins when + # the two are the wrong way round. The constructor rejects that pair, + # but both stay writable afterwards. Taking the minimum also keeps + # the schema's requirement that max-ms be at least base-ms true by + # construction -- it is a cross-property invariant JSON Schema cannot + # express, so the producer is the only thing that can hold it. + report = {'type': 'exponential', + 'base-ms': _required_ms(min(policy.base_delay, policy.max_delay)), + 'max-ms': _required_ms(policy.max_delay)} + # Absent means unlimited, which is what a max_attempts of None is here. + # Anything else that bounds the loop is a real limit: new_schedule runs + # `while max_attempts is None or i < max_attempts`, which compares + # against whatever an integer can be compared with -- a fraction, a + # Decimal, a numpy integer -- and 1.5 admits an i of 0 and of 1, so two + # attempts are made. The count is therefore the ceiling of the limit, + # taken through math.ceil so that every such type is counted rather than + # a hand-written list of the ones thought of here. + attempts = _attempt_ceiling(policy.max_attempts) + if attempts is not None: + report['max-attempts'] = attempts + elif type(policy) is ConstantReconnectionPolicy: + # Read per policy rather than shared with the arm above, because the two + # read the same attribute with different code and disagree about the same + # value: see _constant_reconnection_attempts. + attempts = _constant_reconnection_attempts(policy.max_attempts) + if attempts == 0: + # repeat took the limit and made an empty schedule of it, which a + # negative limit does on every interpreter. The driver never + # reconnects, which is the null arm -- reporting a constant policy + # with max-attempts left out would say the opposite, since the schema + # reads an absent max-attempts as unlimited. + return None + + if _never_comes_due(policy.delay): + # Never reconnects, so the null arm, as in the exponential branch + # above. A negative infinity is not this and falls through: it comes + # due at once, which is the delay of zero the converter makes of it. + return None + report = {'type': 'constant', 'delay-ms': _non_negative_ms(policy.delay)} + if attempts is not None: + # A builtin int, since that is what length_hint returns -- which is + # what keeps a limit of True out of the report as JSON true where a + # number belongs. + report['max-attempts'] = attempts + else: + # Only the name: see _custom_policy_report. + return _custom_policy_report(policy) + + return report + + +def _custom_policy_report(policy): + """ + A user-supplied policy, described by its type name and nothing else. + + The schema permits an implementation to serialize a custom policy's public + attributes as well, and this driver deliberately does not. A policy object + here is an arbitrary Python object whose ``__dict__`` is trivially + reachable, and whatever it happens to hold -- an auth provider, a + credential, a host list -- would go to the server, land in + ``system.clients``, and be readable by anyone who can select from it. There + is no way to tell which attributes are safe, so none of them are reported. + + Keeping user-supplied data out also bounds the report: what the driver sends + is a function of its own settings, so :const:`MAX_DRIVER_CONFIG_LENGTH` is + not something a configuration can drive it into. + """ + return {'type': 'custom', 'name': type(policy).__name__} + + class DriverConfigReporter: """ Builds the :const:`DRIVER_CONFIG_OPTION` ``STARTUP`` option describing the @@ -149,7 +629,117 @@ def _build_report(self, cluster, is_scylla): def _populate_report(self, report, cluster, is_scylla): """ - Extension point for adding the configuration groups themselves to the - report. Empty for now. + Adds the configuration groups themselves to the report. + """ + report['connection'] = self._connection_report(cluster) + + def _connection_report(self, cluster): + """ + The ``connection`` group: what the driver does with a single connection, + as opposed to what it does with a request. + + ``read`` and ``write`` are left out because this driver has no socket + read or write timeout to describe, and ``heartbeat`` because the group + the schema reserves for it is empty in this version, with nowhere to put + :attr:`~.Cluster.idle_heartbeat_interval`. + """ + connection_class = cluster.connection_class + # Read off the class rather than restated here, so that the report + # cannot drift from the limit it describes: it is derived on Connection + # for exactly this, since the report is built before any connection + # exists. + # + # This is the ceiling itself, not one below it: the admission gate in + # HostConnection.borrow_connection is `in_flight < max_request_id`, so a + # request is let through only while in_flight is under this. The pool of + # stream ids is one larger -- ids run from zero to max_request_id + # inclusive -- and reading the pool as the ceiling is the off-by-one this + # field invites. Connection.wait_for_responses, which serves internal + # multi-message waits rather than application queries, does admit one + # more. + max_request_id = connection_class.max_request_id_for( + connection_class.max_in_flight) + if max_request_id < 1: + # max_in_flight is documented as tunable by lower-level + # integrations. Tuned to one it leaves a connection whose gate never + # admits anything, and in-flight.max is a required positiveInteger + # with no way to say "none". + raise ValueError( + "connection_class.max_in_flight is %r, which leaves a connection " + "no capacity for a request; the configuration report cannot " + "describe a connection that admits none" + % (connection_class.max_in_flight,)) + + shard_aware_options = cluster.shard_aware_options + report = { + 'connect': {}, + 'requests': { + 'in-flight': {'max': max_request_id}, + # One below the threshold, for the mirror of the reason above. + # ResponseFuture._on_timeout adds the orphaned id and then tests + # `len(orphaned_request_ids) >= orphaned_threshold`, so a + # connection holding that many is already marked for + # replacement: the most it is ever allowed to hold, which is + # what the schema asks for, is one less. + # + # Floored at zero on its own account rather than on the strength + # of the guard above. A threshold of one or less marks a + # connection on its first orphan -- the count is tested after the + # id is added, so it is never zero -- which tolerates none, and + # orphaned.max is a nonNegativeInteger with no room for the + # negative that subtracting would otherwise give. + 'orphaned': {'max': max(0, connection_class.orphaned_threshold_for( + connection_class.max_in_flight) - 1)}, + }, + 'pool': { + 'shard-aware': { + # Configuration intent, as the schema asks for: reaching a + # shard in one connect also needs the server to advertise + # the port and the client to be able to reach it, and the + # driver falls back transparently when it cannot. + 'enabled': not (shard_aware_options.disable + or shard_aware_options.disable_shardaware_port), + }, + }, + 'socket': _socket_report(cluster.sockopts), + 'reconnection': { + 'policy': _reconnection_policy_report(cluster.reconnection_policy), + }, + } + + connect_timeout_ms = _optional_ms(cluster.connect_timeout) + if connect_timeout_ms is not None: + report['connect']['timeout-ms'] = connect_timeout_ms + + tls = self._tls_report(cluster) + if tls is not None: + report['tls'] = tls + return report + + def _tls_report(self, cluster): + """ + The ``connection.tls`` group, or ``None`` when TLS is not configured. + + Booleans only: the schema is explicit that this group never carries + credentials, keys or host lists, and nothing here reads any. + + Hostname verification is always knowable in this driver. An explicit + ``ssl_context`` carries it as an attribute, and options on their own are + turned into a context by + :meth:`cassandra.connection.Connection._build_ssl_context_from_options`, + which reads the same key this does. + + The context wins when both are given, which is the pair + ``Cluster(cloud=...)`` builds: a context that does not verify, alongside + an ``ssl_options`` of ``{'check_hostname': True}``. The context is what + verifies -- :class:`~cassandra.connection.Connection` builds one from the + options only when it has none, and ``_wrap_socket_from_context`` forwards + ``server_hostname`` but never ``check_hostname`` -- so reading the options + there would report verification that does not happen. """ - pass + if cluster.ssl_context is not None: + return {'hostname-verification': bool(getattr(cluster.ssl_context, + 'check_hostname', False))} + if cluster.ssl_options: + return {'hostname-verification': bool(cluster.ssl_options.get('check_hostname', False))} + return None diff --git a/tests/unit/test_cluster.py b/tests/unit/test_cluster.py index 62e346016a..74ed346c68 100644 --- a/tests/unit/test_cluster.py +++ b/tests/unit/test_cluster.py @@ -319,6 +319,36 @@ def test_connection_factory_reports_the_session_id_and_the_configuration(self): assert factory.call_args.kwargs['session_id'] == cluster.session_id assert isinstance(factory.call_args.kwargs['driver_config_reporter'], DriverConfigReporter) + def test_sockopts_are_materialized(self): + """ + They are applied to every socket the cluster opens and are read again to + build the configuration report, so a one-shot iterable would leave + whichever consumer ran second with nothing -- the report claiming an + option is on while guaranteeing no connection ever sets it. + """ + cluster = Cluster(sockopts=((6, 1, 1) for _ in range(1))) + self.addCleanup(cluster.shutdown) + + # Twice: an iterable would be empty the second time round. + assert cluster.sockopts == [(6, 1, 1)] + assert cluster.sockopts == [(6, 1, 1)] + + unset = Cluster(sockopts=None) + self.addCleanup(unset.shutdown) + assert unset.sockopts is None + + def test_something_that_is_not_a_list_of_options_still_fails_at_connect(self): + """ + sockopts is documented and public, and this constructor used to take + anything: what is wrong with it showed up on the socket, at connect time. + Materializing must not move that failure forward into a constructor that + used to build. + """ + cluster = Cluster(sockopts=42) + self.addCleanup(cluster.shutdown) + + assert cluster.sockopts == 42 + def test_the_reporter_describes_the_cluster_that_owns_it(self): """ The reporter reads its configuration off the cluster when a connection diff --git a/tests/unit/test_driver_config.py b/tests/unit/test_driver_config.py index 079356c01f..51b86bcad3 100644 --- a/tests/unit/test_driver_config.py +++ b/tests/unit/test_driver_config.py @@ -14,12 +14,26 @@ import gc import json +from decimal import Decimal +from fractions import Fraction +from itertools import islice +import socket +import ssl +import struct import unittest from unittest import mock from unittest.mock import Mock +import numpy +import pytest + +from cassandra.cluster import Cluster from cassandra.driver_config import (DriverConfigReporter, DRIVER_CONFIG_OPTION, - DRIVER_CONFIG_SCHEMA_VERSION, MAX_DRIVER_CONFIG_LENGTH) + DRIVER_CONFIG_SCHEMA_VERSION, MAX_DRIVER_CONFIG_LENGTH, + _non_negative_ms, _optional_ms, _reconnection_policy_report, + _required_ms, _socket_report) +from cassandra.policies import (ConstantReconnectionPolicy, ExponentialReconnectionPolicy, + ReconnectionPolicy) from tests.unit.utils import _ClusterlessReporter, ThrowingReporter @@ -42,28 +56,62 @@ def _build_report(self, cluster, is_scylla): return None -def reporter(cluster=None): +_unconfigured_cluster = None + + +def report_cluster(test, **cluster_kwargs): """ - A reporter over `cluster`, defaulting to one whose configuration is never - read because nothing populates the report yet. + A real Cluster for a case to report on, which stays alive for the test. - The cluster is kept alive for as long as the reporter is: it is held weakly, - so a temporary would be collected before the report is built and every test - here would silently exercise the cluster-is-gone path instead. + A stand-in cluster is no longer enough now that the report describes one: + the groups read real settings, and inventing them would keep a test passing + after one had been renamed. + + One unconfigured Cluster is shared by every case that configures nothing, + because building one is not free -- a ThreadPoolExecutor and a _Scheduler + thread go up and down with each -- and most cases here vary nothing about it; + test_recognized_levels_are_unaffected alone would make one per consistency + level. Building a report only reads the cluster, and the cases that do mutate + one build their own inline rather than coming through here. A case that + passes kwargs gets its own either way. """ - cluster = cluster if cluster is not None else Mock() - r = DriverConfigReporter(cluster) - r._strong_cluster = cluster - return r + if cluster_kwargs: + built = Cluster(**cluster_kwargs) + test.addCleanup(built.shutdown) + return built + + global _unconfigured_cluster + if _unconfigured_cluster is None: + _unconfigured_cluster = Cluster() + return _unconfigured_cluster + + +def teardown_module(): + """Shuts the shared Cluster down, since no single test owns it.""" + global _unconfigured_cluster + if _unconfigured_cluster is not None: + _unconfigured_cluster.shutdown() + _unconfigured_cluster = None + + +def reporter(test, **cluster_kwargs): + """The reporter of a real Cluster.""" + return report_cluster(test, **cluster_kwargs)._driver_config_reporter + + +def report_text(test, **cluster_kwargs): + """The report of a real Cluster, as it goes on the wire.""" + built = report_cluster(test, **cluster_kwargs) + return built._driver_config_reporter._build_report(built, is_scylla=True) class DriverConfigReporterTest(unittest.TestCase): def test_reports_the_schema_version(self): options = {} - reporter().add_startup_options(options, is_scylla=True) + reporter(self).add_startup_options(options, is_scylla=True) - assert json.loads(options[DRIVER_CONFIG_OPTION]) == {'version': DRIVER_CONFIG_SCHEMA_VERSION} + assert json.loads(options[DRIVER_CONFIG_OPTION])['version'] == DRIVER_CONFIG_SCHEMA_VERSION def test_report_is_compact_json(self): """ @@ -72,19 +120,20 @@ def test_report_is_compact_json(self): """ options = {} - reporter().add_startup_options(options, is_scylla=True) + reporter(self).add_startup_options(options, is_scylla=True) - assert options[DRIVER_CONFIG_OPTION] == '{"version":%d}' % DRIVER_CONFIG_SCHEMA_VERSION + report = options[DRIVER_CONFIG_OPTION] + assert report == json.dumps(json.loads(report), separators=(',', ':')) def test_report_fits_within_the_length_limit(self): """ - Tripwire for when the actual configuration groups land: a report over the - limit is dropped by add_startup_options, so this would fail with a clear - message instead of the size assertion raising an unrelated KeyError. + A report over the limit is dropped by add_startup_options, so this fails + with a clear message instead of the size assertion raising an unrelated + KeyError. """ options = {} - reporter().add_startup_options(options, is_scylla=True) + reporter(self).add_startup_options(options, is_scylla=True) assert DRIVER_CONFIG_OPTION in options, \ "the report was dropped, it must have exceeded the length limit" @@ -154,6 +203,898 @@ def test_other_options_are_left_alone(self): OversizedReporter().add_startup_options(options, is_scylla=True) MistypedReporter().add_startup_options(options, is_scylla=True) - reporter().add_startup_options(options, is_scylla=True) + reporter(self).add_startup_options(options, is_scylla=True) assert options['APPLICATION_NAME'] == 'app' + + +def connection_report(test, **cluster_kwargs): + """ + The ``connection`` group of the report a real Cluster produces. + + Built from a real Cluster rather than a stand-in: the group is a mapping + from this driver's settings onto the shared schema, so a test that invented + the settings would keep passing after one of them was renamed. + """ + return json.loads(report_text(test, **cluster_kwargs))['connection'] + + +class MillisecondConversionTest(unittest.TestCase): + """ + Durations are float seconds in this driver and integer milliseconds in the + schema, and the three fields differ in what they do at and below zero. + """ + + def test_optional_is_left_out_when_unset_or_disabled(self): + assert _optional_ms(None) is None + assert _optional_ms(0) is None + assert _optional_ms(-1) is None + + def test_optional_converts_seconds(self): + assert _optional_ms(5) == 5000 + assert _optional_ms(2.5) == 2500 + + def test_a_whole_millisecond_survives_the_conversion(self): + """ + Binary floating point often lands the product just under its integer -- + 1.005 seconds multiplies out to 1004.9999999999999 -- so truncating + loses a millisecond and describes a timeout nobody configured. Swept + rather than spot-checked: 372 of these used to come back low. + """ + assert 1.005 * 1000 != 1005 # the premise, in case it ever stops being true + assert _optional_ms(1.005) == 1005 + assert _non_negative_ms(1.005) == 1005 + + wrong = [ms for ms in range(1, 60001) if _optional_ms(ms / 1000) != ms] + assert wrong == [] + wrong = [ms for ms in range(1, 60001) if _non_negative_ms(ms / 1000) != ms] + assert wrong == [] + + def test_a_configured_duration_never_reports_as_zero(self): + """ + positiveInteger cannot express it, and a sub-millisecond timeout is + still a timeout: reporting zero would be a value the schema rejects. + """ + assert _optional_ms(0.0004) == 1 + assert _required_ms(0.0004) == 1 + + def test_required_falls_back_rather_than_being_left_out(self): + assert _required_ms(0) == 1 + assert _required_ms(-1) == 1 + assert _required_ms(None) == 1 + + def test_non_negative_never_truncates_a_wait_to_no_wait(self): + """ + Zero is not "very little" for these fields, it is the driver skipping + the wait: the schema reads it as "do not wait" / "immediately". A + configured sub-millisecond wait is one the driver really takes -- + _wait_for_schema_agreement bypasses agreement only at zero or less -- so + truncating it to zero would report the opposite. + """ + for seconds in (0.0004, 0.0005, 0.0009): + assert _non_negative_ms(seconds) == 1, seconds + + # And the two converters agree wherever both have an answer. + for seconds in (0.0004, 0.001, 2.5): + assert _non_negative_ms(seconds) == _optional_ms(seconds), seconds + + def test_non_negative_keeps_zero(self): + """ + Zero means "do not wait" or "reconnect immediately" for the fields that + take it, so it is a value rather than the absence of one. + """ + assert _non_negative_ms(0) == 0 + assert _non_negative_ms(10) == 10000 + assert _non_negative_ms(None) == 0 + assert _non_negative_ms(-5) == 0 + + + def test_a_duration_that_is_not_finite_is_left_out(self): + """ + inf and nan reach every duration setting unchallenged -- nothing + validates one, and a nan passes even the checks that reject a negative, + since every comparison against one is false -- and both used to raise + out of int(), which cost the whole report rather than the one key. + + These are optional fields, and absence is already how the report says + the driver imposes no limit: which is what an infinite timeout asks for, + and the nearest thing to the truth for a nan. + """ + for seconds in (float('inf'), float('-inf'), float('nan')): + assert _optional_ms(seconds) is None, seconds + + # A value that is not a number is a different failure and still raises: + # leaving the key out would answer a misconfigured duration with the + # absence that means "no limit", where the driver will not get as far as + # using it -- socket.settimeout rejects one outright. + with pytest.raises(TypeError): + _optional_ms('lots') + + def test_a_required_duration_that_is_not_finite_raises(self): + """ + Rather than flooring to one millisecond the way it floors zero. An + unbounded delay reported as 1ms is the furthest thing from it the field + can hold, so the report is dropped instead -- and the message says why, + where an OverflowError under the generic warning would not. + """ + for seconds in (float('inf'), float('nan')): + with pytest.raises(ValueError, match='cannot be reported'): + _required_ms(seconds) + with pytest.raises(ValueError, match='cannot be reported'): + _non_negative_ms(seconds) + + # None is not finite either, and means unset rather than undescribable + # for both of them: the order of the two checks is what keeps it so. + assert _required_ms(None) == 1 + assert _non_negative_ms(None) == 0 + + # Nor is a negative infinity one of these. Its deadline is already past, + # so the timer fires at once -- which is what every other negative delay + # does here, and it reports as they do rather than raising. + assert _required_ms(float('-inf')) == _required_ms(-1) == 1 + assert _non_negative_ms(float('-inf')) == _non_negative_ms(-5) == 0 + + +class ConnectionGroupTest(unittest.TestCase): + def test_defaults(self): + assert connection_report(self) == { + 'connect': {'timeout-ms': 5000}, + 'requests': {'in-flight': {'max': 32767}, 'orphaned': {'max': 24575}}, + 'pool': {'shard-aware': {'enabled': True}}, + 'socket': {'tcp-no-delay': False, 'keep-alive': False, 'reuse-address': False}, + 'reconnection': {'policy': {'type': 'exponential', + 'base-ms': 1000, 'max-ms': 600000}}, + } + + def test_no_read_or_write_or_heartbeat_group(self): + """ + This driver has no socket read or write timeout, and the group the + schema reserves for heartbeat settings is empty in this version, so + idle_heartbeat_interval has nowhere to go. + """ + report = connection_report(self, idle_heartbeat_interval=7) + + for absent in ('read', 'write', 'heartbeat', 'node-preference'): + assert absent not in report + + def test_connect_timeout(self): + assert connection_report(self, connect_timeout=12)['connect'] == {'timeout-ms': 12000} + # positiveInteger, so a disabled timeout is an absent key rather than a + # zero the schema would reject. + assert connection_report(self, connect_timeout=0)['connect'] == {} + + def test_in_flight_is_the_admission_ceiling_not_the_stream_pool(self): + """ + Driven through the gate itself rather than asserted against a constant: + borrow_connection admits while `in_flight < max_request_id`, so the most + a connection ever carries is max_request_id, one short of the number of + stream ids it has. + """ + max_request_id = Cluster.connection_class.max_request_id_for( + Cluster.connection_class.max_in_flight) + + in_flight = 0 + while in_flight < max_request_id: + in_flight += 1 + + assert connection_report(self)['requests']['in-flight']['max'] == in_flight + + def test_in_flight_matches_what_a_connection_will_allow(self): + """ + Reported off the connection class rather than hardcoded, since that is + what a connection derives its own limit from. + """ + report = connection_report(self) + max_request_id = Cluster.connection_class.max_request_id_for( + Cluster.connection_class.max_in_flight) + + # The ceiling itself: borrow_connection admits only while in_flight is + # under max_request_id, so the stream id pool is one larger than the + # concurrency it permits. + assert report['requests']['in-flight']['max'] == max_request_id + # One below the threshold: the gate marks a connection at that count, + # so the most it is ever allowed to hold is one less. + assert report['requests']['orphaned']['max'] == \ + Cluster.connection_class.orphaned_threshold_for( + Cluster.connection_class.max_in_flight) - 1 + + def test_orphaned_is_the_tolerated_count_not_the_replacement_trigger(self): + """ + Driven through the gate itself rather than asserted against the + attribute: ResponseFuture._on_timeout adds the orphaned id and then + tests `len(orphaned_request_ids) >= orphaned_threshold`, so a connection + holding that many is already marked for replacement. What the schema + asks for is the most it is allowed to hold, which is one less. + """ + threshold = Cluster.connection_class.orphaned_threshold_for( + Cluster.connection_class.max_in_flight) + + orphans, marked_at = set(), None + for request_id in range(threshold + 2): + orphans.add(request_id) + if len(orphans) >= threshold and marked_at is None: + marked_at = len(orphans) + + assert marked_at == threshold + assert connection_report(self)['requests']['orphaned']['max'] == marked_at - 1 + + def test_a_threshold_that_tolerates_nothing_reports_zero(self): + """ + The count is tested after the orphaned id is added, so a threshold of + one or less marks a connection on its first orphan and tolerates none. + Subtracting one would give a negative, which orphaned.max -- + a nonNegativeInteger -- has no room for. + """ + class Marked(Cluster.connection_class): + @staticmethod + def orphaned_threshold_for(max_in_flight): + return 0 + + with mock.patch.object(Cluster, 'connection_class', Marked): + report = connection_report(self) + + assert report['requests']['orphaned']['max'] == 0 + + def test_shard_awareness(self): + assert connection_report(self)['pool'] == {'shard-aware': {'enabled': True}} + + for disabling in ({'disable': True}, {'disable_shardaware_port': True}): + report = connection_report(self, shard_aware_options=disabling) + assert report['pool'] == {'shard-aware': {'enabled': False}}, disabling + + +class SocketOptionsTest(unittest.TestCase): + OFF = {'tcp-no-delay': False, 'keep-alive': False, 'reuse-address': False} + + def test_unset_options_report_the_platform_default(self): + """ + The driver sets no socket options of its own, so an option absent from + sockopts is left wherever the operating system has it, which for a fresh + TCP socket is off. + """ + assert _socket_report(None) == self.OFF + assert _socket_report([]) == self.OFF + + def test_configured_flags(self): + report = _socket_report([ + (socket.IPPROTO_TCP, socket.TCP_NODELAY, 1), + (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1), + (socket.SOL_SOCKET, socket.SO_REUSEADDR, 1), + ]) + + assert report == {'tcp-no-delay': True, 'keep-alive': True, 'reuse-address': True} + + def test_an_option_of_any_integer_type_is_read(self): + """ + setsockopt takes anything with __index__, so a numpy integer sets an + option just as a builtin one does. Checked against the kernel, since the + claim is about what setsockopt accepts. + """ + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + for value in (numpy.int64(1), numpy.int64(0), True, 1, 0): + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, value) + kernel = bool(sock.getsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY)) + + report = _socket_report([(socket.IPPROTO_TCP, socket.TCP_NODELAY, value)]) + + assert report['tcp-no-delay'] is kernel, value + + size = _socket_report( + [(socket.SOL_SOCKET, socket.SO_RCVBUF, numpy.int64(65536))])['receive-buffer'] + assert size == {'size-bytes': 65536} + assert type(size['size-bytes']) is int + + def test_a_flag_set_to_zero_is_off(self): + report = _socket_report([(socket.IPPROTO_TCP, socket.TCP_NODELAY, 0)]) + + assert report['tcp-no-delay'] is False + + def test_the_last_setting_of_an_option_wins(self): + """ + As it does in the loop that applies them, where each setsockopt call + overwrites the one before. + """ + report = _socket_report([ + (socket.IPPROTO_TCP, socket.TCP_NODELAY, 1), + (socket.IPPROTO_TCP, socket.TCP_NODELAY, 0), + ]) + + assert report['tcp-no-delay'] is False + + def test_buffer_sizes(self): + report = _socket_report([ + (socket.SOL_SOCKET, socket.SO_RCVBUF, 65536), + (socket.SOL_SOCKET, socket.SO_SNDBUF, 32768), + ]) + + assert report['receive-buffer'] == {'size-bytes': 65536} + assert report['send-buffer'] == {'size-bytes': 32768} + + def test_buffer_sizes_are_left_out_when_not_a_positive_size(self): + report = _socket_report([(socket.SOL_SOCKET, socket.SO_RCVBUF, 0)]) + + assert 'receive-buffer' not in report + + def test_linger(self): + """ + SO_LINGER is the one option whose value is a packed struct rather than + an integer, because that is what setsockopt takes. + """ + report = _socket_report([ + (socket.SOL_SOCKET, socket.SO_LINGER, struct.pack('ii', 1, 30)), + ]) + + assert report['linger'] == {'interval-s': 30} + + def test_linger_is_left_out_when_disabled_or_unreadable(self): + for value in (struct.pack('ii', 0, 30), b'short', 30, None): + report = _socket_report([(socket.SOL_SOCKET, socket.SO_LINGER, value)]) + assert 'linger' not in report, value + + def test_a_flag_packed_as_a_buffer(self): + """ + setsockopt takes an integer option either as an int or as a packed + buffer, and the kernel honours both, so the report has to read both. A + packed buffer is non-empty bytes, so bool() alone calls every option + enabled -- including one packed to zero to turn it off, which is the + configuration this gets wrong in the worst direction. + """ + for packed, expected in ((struct.pack('i', 0), False), + (struct.pack('i', 1), True), + # Any width: the value reaches the kernel as + # raw bytes, so a zero is a zero regardless. + (struct.pack('q', 0), False), + (struct.pack('q', 1), True)): + report = _socket_report([(socket.IPPROTO_TCP, socket.TCP_NODELAY, packed)]) + assert report['tcp-no-delay'] is expected, packed + + def test_the_kernel_really_honours_a_packed_flag(self): + """ + The premise of the test above, read off a socket rather than assumed. + """ + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, struct.pack('i', 0)) + assert sock.getsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY) == 0 + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, struct.pack('i', 1)) + assert sock.getsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY) != 0 + + def test_only_the_leading_int_of_a_buffer_is_read(self): + """ + setsockopt takes the C int at the front of the buffer and ignores what + follows, so reading the whole buffer as one wide integer answers for + bytes the option never had: pack('ii', 0, 1) leaves TCP_NODELAY off + while all eight bytes come to a large non-zero number. + + Checked against a real socket, since the claim is about what the kernel + does rather than about this module. + """ + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + for packed in (struct.pack('ii', 0, 1), struct.pack('ii', 1, 0), + struct.pack('i', 0), struct.pack('i', 1), + struct.pack('q', 1)): + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, packed) + kernel = bool(sock.getsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY)) + + report = _socket_report([(socket.IPPROTO_TCP, socket.TCP_NODELAY, packed)]) + + assert report['tcp-no-delay'] is kernel, packed + + def test_a_buffer_too_short_for_an_int_is_skipped(self): + """ + setsockopt rejects it, so there is nothing to report for it. + """ + report = _socket_report([(socket.IPPROTO_TCP, socket.TCP_NODELAY, b'ab')]) + + assert report['tcp-no-delay'] is False + + def test_every_buffer_type_setsockopt_takes_is_read(self): + """ + memoryview among them, which the linger group used to drop. + """ + packed = struct.pack('ii', 1, 30) + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, memoryview(packed)) + + for value in (packed, bytearray(packed), memoryview(packed)): + report = _socket_report([(socket.SOL_SOCKET, socket.SO_LINGER, value)]) + assert report['linger'] == {'interval-s': 30}, type(value) + + for value in (memoryview(struct.pack('i', 1)), b'abc'): + report = _socket_report([(socket.SOL_SOCKET, socket.SO_LINGER, value)]) + assert 'linger' not in report, type(value) + + def test_a_buffer_size_packed_as_a_buffer(self): + """ + Same root cause, milder symptom: a packed size used to be dropped rather + than misread, so the option went unreported instead of wrong. + """ + report = _socket_report([ + (socket.SOL_SOCKET, socket.SO_RCVBUF, struct.pack('i', 65536)), + (socket.SOL_SOCKET, socket.SO_SNDBUF, struct.pack('i', 0)), + ]) + + assert report['receive-buffer'] == {'size-bytes': 65536} + # Zero is not a size, whichever form it arrives in. + assert 'send-buffer' not in report + + def test_packed_and_plain_values_mix(self): + """ + Last one wins across both forms, as it does in the loop that applies + them. + """ + report = _socket_report([ + (socket.IPPROTO_TCP, socket.TCP_NODELAY, 1), + (socket.IPPROTO_TCP, socket.TCP_NODELAY, struct.pack('i', 0)), + (socket.SOL_SOCKET, socket.SO_KEEPALIVE, struct.pack('i', 0)), + (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1), + ]) + + assert report['tcp-no-delay'] is False + assert report['keep-alive'] is True + + def test_a_value_that_is_neither_reports_the_default(self): + """ + setsockopt would reject it at connect time; there is nothing to report + for it, and guessing enabled would be the same mistake as before. + """ + report = _socket_report([(socket.IPPROTO_TCP, socket.TCP_NODELAY, 'yes')]) + + assert report['tcp-no-delay'] is False + + def test_an_entry_that_cannot_be_a_key_is_skipped(self): + """ + The guard has to cover recording the option, not only unpacking it: a + level or name that cannot be a dict key raises when it is recorded, and + an entry the user got wrong is not this module's to fail the whole + report over. + """ + for entry in (([1], 2, 3), (1, {}, 3)): + report = _socket_report([entry]) + + assert report == {'tcp-no-delay': False, 'keep-alive': False, + 'reuse-address': False}, entry + + # And it costs only itself, not the entries beside it. + report = _socket_report([([1], 2, 3), + (socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)]) + assert report['tcp-no-delay'] is True + + def test_sockopts_that_are_not_a_sequence_at_all(self): + """ + _connect_socket will fail on it and there is nothing to describe, but + the report still says what it truthfully can. Cluster() rejects a + non-iterable, so this is reached by assigning one afterwards. + """ + for sockopts in (5, 'nonsense'): + report = _socket_report(sockopts) + + assert report == {'tcp-no-delay': False, 'keep-alive': False, + 'reuse-address': False}, sockopts + + def test_a_malformed_entry_does_not_cost_the_whole_report(self): + """ + Reporting is best effort, and this module's part of that is to leave out + what it cannot describe rather than to take the other groups with it. + """ + cluster = Cluster(sockopts=[([1], 2, 3)]) + self.addCleanup(cluster.shutdown) + options = {} + + cluster._driver_config_reporter.add_startup_options(options, is_scylla=True) + + assert DRIVER_CONFIG_OPTION in options + # The group the bad entry sits in still arrives, describing everything + # about the connection bar the option that could not be read. + assert 'connection' in json.loads(options[DRIVER_CONFIG_OPTION]) + + def test_malformed_entries_are_skipped(self): + """ + setsockopt also takes a (level, name, None, optlen) form, and an entry + that is neither is the user's to get wrong when the connection applies + it, not this module's to fail on. + """ + report = _socket_report([ + (socket.SOL_SOCKET, socket.SO_RCVBUF, None, 4), + 'nonsense', + None, + (socket.IPPROTO_TCP, socket.TCP_NODELAY, 1), + ]) + + assert report['tcp-no-delay'] is True + + +class ReconnectionPolicyReportTest(unittest.TestCase): + def test_exponential(self): + report = _reconnection_policy_report(ExponentialReconnectionPolicy(2.0, 60.0)) + + assert report == {'type': 'exponential', 'base-ms': 2000, 'max-ms': 60000} + + def test_constant(self): + report = _reconnection_policy_report(ConstantReconnectionPolicy(1.5)) + + assert report == {'type': 'constant', 'delay-ms': 1500} + + def test_a_sub_millisecond_delay_is_not_an_immediate_one(self): + """ + A configured wait below a millisecond is still a wait, and the schema + reads a zero delay as "reconnect immediately", so the two must not + report alike. + """ + assert _reconnection_policy_report( + ConstantReconnectionPolicy(0.0004))['delay-ms'] == 1 + assert _reconnection_policy_report( + ConstantReconnectionPolicy(0))['delay-ms'] == 0 + + def test_a_delay_that_never_comes_due_is_the_null_arm(self): + """ + _Scheduler tests `run_at <= time.time()` and Timer `time_now >= + self.end`, and neither is ever true of an infinite delay or of a nan -- + so the reconnection is queued and never run, which is the same thing the + null arm says about a schedule that yields nothing. + + Reporting a delay instead would have to invent a number, and the field + is a positiveInteger with no room for the one that was configured. + """ + for delay in (float('inf'), float('nan')): + assert _reconnection_policy_report( + ConstantReconnectionPolicy(delay)) is None, delay + assert _reconnection_policy_report( + ExponentialReconnectionPolicy(delay, delay)) is None, delay + + def test_a_delay_already_past_reconnects_at_once(self): + """ + `time.time() + float('-inf')` is behind every reading of the clock, so + the timer fires at the first opportunity rather than never -- the + opposite of the case above, and the schema says it with a delay of zero. + + The constructors reject a negative, so this is reached by assignment. + """ + policy = ConstantReconnectionPolicy(1.0) + policy.delay = float('-inf') + + assert _reconnection_policy_report(policy) == {'type': 'constant', + 'delay-ms': 0} + + def test_a_finite_delay_is_still_reported(self): + """The guard above is not so eager that it takes ordinary policies.""" + assert _reconnection_policy_report( + ConstantReconnectionPolicy(1.5)) == {'type': 'constant', 'delay-ms': 1500} + + def test_delays_given_the_wrong_way_round(self): + """ + _add_jitter clamps every delay with + `min(max(base_delay, delay), max_delay)`, so max_delay wins when the two + are inverted and the schedule is flat at it. Reporting base_delay would + claim a first delay never waited, and would emit base-ms above max-ms -- + which the schema forbids the producer to do and cannot itself catch, + being a comparison between siblings. + + The constructor rejects the pair, so this is reached by assignment. + """ + policy = ExponentialReconnectionPolicy(1.0, 60.0) + policy.base_delay, policy.max_delay = 100.0, 1.0 + + assert list(islice(policy.new_schedule(), 3)) == [1.0, 1.0, 1.0] + + report = _reconnection_policy_report(policy) + + assert report['base-ms'] == 1000 + assert report['max-ms'] == 1000 + + def test_the_delay_window_is_never_inverted(self): + for base, maximum in ((1.0, 60.0), (100.0, 1.0), (5.0, 5.0), (0.0004, 0.0009)): + policy = ExponentialReconnectionPolicy(1.0, 60.0) + policy.base_delay, policy.max_delay = base, maximum + + report = _reconnection_policy_report(policy) + + assert report['max-ms'] >= report['base-ms'], (base, maximum) + + def test_a_constant_delay_of_zero_is_reported(self): + """ + nonNegativeInteger here: zero means reconnect immediately, which is a + setting rather than the absence of one. + """ + report = _reconnection_policy_report(ConstantReconnectionPolicy(0)) + + assert report['delay-ms'] == 0 + + def test_max_attempts(self): + report = _reconnection_policy_report(ConstantReconnectionPolicy(1, max_attempts=5)) + + assert report['max-attempts'] == 5 + + def test_unlimited_attempts_are_left_out(self): + """ + None means unlimited to both policies, and so does zero to the constant + one: its `if self.max_attempts` is falsy for zero and falls through to + an unbounded repeat. + """ + for policy in (ConstantReconnectionPolicy(1, max_attempts=None), + ConstantReconnectionPolicy(1, max_attempts=0), + ExponentialReconnectionPolicy(1.0, 2.0, max_attempts=None)): + assert 'max-attempts' not in _reconnection_policy_report(policy) + + def test_an_exponential_policy_that_never_attempts_is_reported_as_no_policy(self): + """ + The two policies read a max_attempts of zero in opposite ways. The + exponential one drives + `while max_attempts is None or i < max_attempts`, so zero yields nothing + and the driver never reconnects -- the schema's null arm. Reporting it + as an exponential policy with max-attempts left out would say the + opposite, since absent reads as unlimited. + """ + assert list(ExponentialReconnectionPolicy(1.0, 2.0, max_attempts=0).new_schedule()) == [] + assert _reconnection_policy_report( + ExponentialReconnectionPolicy(1.0, 2.0, max_attempts=0)) is None + + # The constant policy really is unlimited at zero, so it keeps its arm. + assert next(ConstantReconnectionPolicy(1, max_attempts=0).new_schedule()) == 1 + assert _reconnection_policy_report( + ConstantReconnectionPolicy(1, max_attempts=0))['type'] == 'constant' + + def test_a_policy_that_never_reconnects_reports_null(self): + """ + The null arm reached from a real configuration rather than from no + policy at all. That it survives schema validation is asserted where the + report is a whole conformant document -- see ReportConformsToTheSchemaTest + -- which it is not yet at this point in the series. + """ + report = json.loads(report_text( + self, reconnection_policy=ExponentialReconnectionPolicy(1.0, 2.0, max_attempts=0))) + + assert report['connection']['reconnection']['policy'] is None + + def test_an_exponential_policy_with_no_base_delay_is_constant(self): + """ + The schedule is base_delay * 2 ** i, so a base of zero stays zero + however high max_delay is: the driver reconnects immediately, every + time. Reporting the exponential arm would claim a delay that grows, and + its base is a positiveInteger that cannot hold the zero anyway. + """ + schedule = list(islice(ExponentialReconnectionPolicy(0, 60.0).new_schedule(), 6)) + assert schedule == [0, 0, 0, 0, 0, 0] + + assert _reconnection_policy_report( + ExponentialReconnectionPolicy(0, 60.0)) == {'type': 'constant', 'delay-ms': 0} + + def test_a_limit_that_stops_the_schedule_is_the_null_arm(self): + """ + Zero, a negative and a nan all leave the schedule yielding nothing, so + the driver never reconnects -- the null arm. Leaving max-attempts out + would say the opposite, since the schema reads its absence as unlimited. + + A nan reaches the constructor unchallenged, since `nan < 0` is false + just as every other comparison against one is; a negative is reached by + assignment. + """ + for limit in (0, float('nan')): + policy = ExponentialReconnectionPolicy(1.0, 2.0, max_attempts=limit) + assert list(policy.new_schedule()) == [], limit + + assert _reconnection_policy_report(policy) is None, limit + + assigned = ExponentialReconnectionPolicy(1.0, 2.0, max_attempts=1) + assigned.max_attempts = -1 + assert list(assigned.new_schedule()) == [] + assert _reconnection_policy_report(assigned) is None + + def test_a_limit_the_schedule_cannot_compare_against_is_the_null_arm(self): + """ + The probe runs the policy's own `i < max_attempts`, so a limit that is + not a number raises rather than answering. It is still a schedule that + yields nothing, and for the same reason it must not cost the report: the + TypeError comes out of _ReconnectionHandler.start too, which pulls the + first delay with a bare next(), so no attempt is ever scheduled. + + Absent max-attempts would say the opposite -- this schema reads it as + unlimited -- and it is what _attempt_ceiling makes of such a limit. + """ + policy = ExponentialReconnectionPolicy(1.0, 2.0, max_attempts=1) + policy.max_attempts = 'lots' + + with pytest.raises(TypeError): + list(policy.new_schedule()) + + assert _reconnection_policy_report(policy) is None + + def test_such_a_limit_does_not_cost_the_rest_of_the_report(self): + """ + The whole point of answering rather than raising: one unnameable limit + must not take the groups it has nothing to do with. + """ + policy = ExponentialReconnectionPolicy(1.0, 2.0, max_attempts=1) + policy.max_attempts = 'lots' + + report = json.loads(report_text(self, reconnection_policy=policy)) + + assert report['connection']['reconnection']['policy'] is None + # The rest of the group came out: the limit costs its own key and no + # other. + assert report['connection']['requests']['in-flight']['max'] > 0 + + def test_an_unlimited_schedule_is_not_the_null_arm(self): + """ + The distinction the probe exists for: float('inf') fails the same + arithmetic that nan does -- no integer names either -- but it means the + opposite, and the schedule is what tells them apart. + """ + for limit in (None, float('inf')): + policy = ExponentialReconnectionPolicy(1.0, 2.0, max_attempts=limit) + assert len(list(islice(policy.new_schedule(), 6))) == 6, limit + + report = _reconnection_policy_report(policy) + + assert report is not None, limit + assert 'max-attempts' not in report, limit + + def test_never_reconnecting_still_wins_over_a_zero_base(self): + """ + Zero attempts means the schedule is empty, which the null arm says and + a constant delay of zero would contradict. + """ + assert _reconnection_policy_report( + ExponentialReconnectionPolicy(0, 60.0, max_attempts=0)) is None + + def test_a_fractional_exponential_limit_is_finite(self): + """ + new_schedule loops `while max_attempts is None or i < max_attempts`, + which compares against a fraction as readily as an integer: 1.5 admits + an i of 0 and of 1, so two attempts are made. Leaving the key out would + report that as unlimited. + """ + for limit, attempts in ((0.5, 1), (1.5, 2), (2.5, 3)): + policy = ExponentialReconnectionPolicy(1.0, 60.0, max_attempts=limit) + assert len(list(policy.new_schedule())) == attempts, limit + + assert _reconnection_policy_report(policy)['max-attempts'] == attempts, limit + + def test_a_fractional_constant_limit_has_no_count_to_report(self): + """ + The two policies read the same attribute with different code and + disagree about the same value, which is why the limit is read per + policy. This one hands max_attempts to itertools.repeat, which takes + only an integer, so a fraction is a policy that raises when it + reconnects rather than one that counts. + """ + policy = ConstantReconnectionPolicy(1.0, max_attempts=1.5) + with pytest.raises(TypeError): + policy.new_schedule() + + assert 'max-attempts' not in _reconnection_policy_report(policy) + + def test_a_limit_of_any_countable_type_is_reported(self): + """ + The exponential schedule compares `i < max_attempts`, which works + against anything an integer can be compared with, so a limit need not be + a builtin number to bound it. Reporting only int and float left these + finite schedules described as unlimited. + """ + for limit, attempts in ((Decimal('2'), 2), (Fraction(3, 2), 2), (True, 1)): + policy = ExponentialReconnectionPolicy(1.0, 60.0, max_attempts=limit) + assert len(list(policy.new_schedule())) == attempts, limit + + assert _reconnection_policy_report(policy)['max-attempts'] == attempts, limit + + def test_a_constant_limit_is_whatever_repeat_accepts(self): + """ + new_schedule hands max_attempts to itertools.repeat, and what that + accepts is not the same on every interpreter: CPython wants __index__ + and rejects a Decimal, PyPy takes one and counts it. So the report is + checked against the schedule the policy actually produces rather than + against either interpreter's rule -- a driver on PyPy really does + reconnect twice where the same configuration raises on CPython. + + A bool is one repeat either way, reported as the number 1, since the + schema wants an integer and JSON true is not one. + """ + report = _reconnection_policy_report( + ConstantReconnectionPolicy(1.0, max_attempts=True)) + assert report['max-attempts'] == 1 + assert 'true' not in json.dumps(report) + + for limit in (Decimal('2'), Fraction(3, 2), 3): + policy = ConstantReconnectionPolicy(1.0, max_attempts=limit) + try: + attempts = len(list(policy.new_schedule())) + except TypeError: + # The policy raises when it reconnects: no count to report. + attempts = None + + report = _reconnection_policy_report( + ConstantReconnectionPolicy(1.0, max_attempts=limit)) + + if attempts is None: + assert 'max-attempts' not in report, limit + else: + assert report['max-attempts'] == attempts, limit + + def test_a_limit_that_makes_an_empty_schedule_never_reconnects(self): + """ + A negative limit is truthy, so new_schedule passes it to repeat and gets + an empty schedule back -- on every interpreter. The driver never + reconnects, which is the null arm; leaving max-attempts out would say + unlimited. Only reachable by assignment, since the constructor rejects a + negative. + """ + policy = ConstantReconnectionPolicy(1.0, max_attempts=1) + policy.max_attempts = -5 + + assert list(policy.new_schedule()) == [] + assert _reconnection_policy_report(policy) is None + + def test_reported_counts_are_builtin_ints(self): + """ + Whatever type the limit arrived as, what goes on the wire is a JSON + number. + """ + for policy in (ExponentialReconnectionPolicy(1.0, 60.0, max_attempts=Decimal('2')), + ConstantReconnectionPolicy(1.0, max_attempts=True)): + attempts = _reconnection_policy_report(policy)['max-attempts'] + assert type(attempts) is int, policy + + def test_no_policy(self): + assert _reconnection_policy_report(None) is None + + def test_a_custom_policy_is_named_and_nothing_more(self): + class SecretiveReconnectionPolicy(ReconnectionPolicy): + def __init__(self): + self.password = 'hunter2' + + def new_schedule(self): + return iter(()) + + report = _reconnection_policy_report(SecretiveReconnectionPolicy()) + + assert report == {'type': 'custom', 'name': 'SecretiveReconnectionPolicy'} + + def test_a_subclass_of_a_built_in_is_custom(self): + """ + Dispatch is on the exact type: a subclass is a policy the driver knows + nothing about, and describing it as its parent would put the parent's + parameters against behaviour it does not have. + """ + class Tweaked(ExponentialReconnectionPolicy): + pass + + report = _reconnection_policy_report(Tweaked(1.0, 2.0)) + + assert report == {'type': 'custom', 'name': 'Tweaked'} + + +class TlsReportTest(unittest.TestCase): + def report(self, **cluster_kwargs): + return connection_report(self, **cluster_kwargs).get('tls') + + def test_absent_when_tls_is_not_configured(self): + assert self.report() is None + + def test_hostname_verification_from_an_ssl_context(self): + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + assert self.report(ssl_context=context) == {'hostname-verification': False} + + verifying = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + assert verifying.check_hostname + assert self.report(ssl_context=verifying) == {'hostname-verification': True} + + def test_hostname_verification_from_ssl_options(self): + """ + Options on their own are turned into a context by the connection, which + reads the same key this does. + """ + assert self.report(ssl_options={'check_hostname': True}) == {'hostname-verification': True} + assert self.report(ssl_options={'ca_certs': '/dev/null'}) == {'hostname-verification': False} + + def test_no_credentials_are_reported(self): + """ + The schema is explicit that this group carries booleans only, never + credentials, keys or host lists. + """ + report = self.report(ssl_options={'check_hostname': True, + 'keyfile': '/secret/key.pem', + 'certfile': '/secret/cert.pem', + 'ca_certs': '/secret/ca.pem'}) + + assert report == {'hostname-verification': True} From ddcbe1c31f27c980a54f508991afe10522051969 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Tue, 25 Aug 2026 14:12:53 +0200 Subject: [PATCH 163/200] DRIVER-379: Report the control-plane group The timeouts on the driver's own queries: the ones it runs to discover the cluster rather than on behalf of the application. The two system-query timeouts are different things, which is why the schema has both. client-side-ms is how long the driver waits for a reply. server-side-ms is a limit the server enforces, which this driver applies by appending USING TIMEOUT -- a ScyllaDB extension, so it is reported only against a ScyllaDB node, mirroring ControlConnection._try_connect: the report describes what the driver will do, not only what it was configured to do. Schema agreement stays in the report at zero, which says the driver does not wait for agreement -- a setting rather than the absence of one. Its timeout-ms is also the one field here that a duration the report cannot carry takes down with it. A wait of inf or nan has no describable length and the schema requires the key, so there is no conformant document to be had and the report is dropped -- with a message saying which kind of value did it, where an OverflowError under the generic warning would not. The other two are optional and are simply left out: server-side-ms because the builder cannot carry one either, being a timedelta, so no USING TIMEOUT clause is appended at all. Co-Authored-By: Claude Opus 5 (1M context) --- cassandra/driver_config.py | 67 ++++++++++++++ tests/unit/test_driver_config.py | 147 +++++++++++++++++++++++++++++++ 2 files changed, 214 insertions(+) diff --git a/cassandra/driver_config.py b/cassandra/driver_config.py index 5a62821884..8376eb8a58 100644 --- a/cassandra/driver_config.py +++ b/cassandra/driver_config.py @@ -18,6 +18,7 @@ incident can inspect the settings of a client without access to its host. """ +import datetime import json import logging import math @@ -142,6 +143,33 @@ def _milliseconds(seconds): return int(round(seconds * 1000)) +def _server_side_timeout_ms(seconds): + """ + The server-side limit the driver will actually impose, in milliseconds, or + ``None`` when it will impose none. + + Converted the way :func:`cassandra.util.maybe_add_timeout_to_query` converts + it rather than the way every other duration here is converted, because that + builder is what the server ends up being told: it divides a timedelta into + whole milliseconds, truncating, and appends no ``USING TIMEOUT`` at all when + that comes to zero. Rounding up, or promoting a sub-millisecond value to one + as the other converters do, would report a limit the server is never given + -- 0.0016 seconds is sent as 1ms, and 0.0006 seconds is not sent at all. + + A negative value is left out too. The builder does append it, but the clause + is malformed and the server rejects it, and the schema has no way to carry a + negative anyway. + + A duration that is not finite is left out as well. The builder cannot carry + one either -- it is a timedelta, and timedelta rejects both -- so no clause + is appended, which is what an absent server-side-ms says. + """ + if seconds is None or not _finite(seconds): + return None + ms = int(datetime.timedelta(seconds=seconds) / datetime.timedelta(milliseconds=1)) + return ms if ms > 0 else None + + def _optional_ms(seconds): """ Milliseconds for a schema field of type ``positiveInteger``, or ``None`` @@ -632,6 +660,7 @@ def _populate_report(self, report, cluster, is_scylla): Adds the configuration groups themselves to the report. """ report['connection'] = self._connection_report(cluster) + report['control-plane'] = self._control_plane_report(cluster, is_scylla) def _connection_report(self, cluster): """ @@ -716,6 +745,44 @@ def _connection_report(self, cluster): report['tls'] = tls return report + def _control_plane_report(self, cluster, is_scylla): + """ + The ``control-plane`` group: the timeouts on the driver's own queries, + the ones it runs to discover the cluster rather than on behalf of the + application. + + The two system-query timeouts are different things, which is why the + schema has both. The client-side one is how long the driver waits for a + reply; the server-side one is a limit the server enforces, which this + driver applies by appending ``USING TIMEOUT`` to the query. + """ + timeout = {} + + client_side_ms = _optional_ms(cluster.control_connection_timeout) + if client_side_ms is not None: + timeout['client-side-ms'] = client_side_ms + + # USING TIMEOUT is a ScyllaDB extension, so against anything else the + # driver does not append it and there is no server-side limit to report: + # ControlConnection._try_connect drops metadata_request_timeout on a + # connection with no sharding info, and this reports what the driver + # will do rather than only what it was configured to do. A configured + # zero means the same thing, letting the server's own default apply. + if is_scylla: + server_side_ms = _server_side_timeout_ms(cluster.metadata_request_timeout) + if server_side_ms is not None: + timeout['server-side-ms'] = server_side_ms + + return { + 'queries': {'system': {'timeout': timeout}}, + 'schema': { + # nonNegativeInteger and required: zero means the driver does + # not wait for schema agreement at all, which is a setting + # rather than the absence of one. + 'agreement': {'timeout-ms': _non_negative_ms(cluster.max_schema_agreement_wait)}, + }, + } + def _tls_report(self, cluster): """ The ``connection.tls`` group, or ``None`` when TLS is not configured. diff --git a/tests/unit/test_driver_config.py b/tests/unit/test_driver_config.py index 51b86bcad3..f249b6c944 100644 --- a/tests/unit/test_driver_config.py +++ b/tests/unit/test_driver_config.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import datetime import gc import json from decimal import Decimal @@ -32,6 +33,7 @@ DRIVER_CONFIG_SCHEMA_VERSION, MAX_DRIVER_CONFIG_LENGTH, _non_negative_ms, _optional_ms, _reconnection_policy_report, _required_ms, _socket_report) +from cassandra.util import maybe_add_timeout_to_query from cassandra.policies import (ConstantReconnectionPolicy, ExponentialReconnectionPolicy, ReconnectionPolicy) from tests.unit.utils import _ClusterlessReporter, ThrowingReporter @@ -1098,3 +1100,148 @@ def test_no_credentials_are_reported(self): 'ca_certs': '/secret/ca.pem'}) assert report == {'hostname-verification': True} + + +def control_plane_report(test, is_scylla=True, **cluster_kwargs): + built = report_cluster(test, **cluster_kwargs) + report = built._driver_config_reporter._build_report(built, is_scylla=is_scylla) + return json.loads(report)['control-plane'] + + +class ControlPlaneGroupTest(unittest.TestCase): + def test_defaults(self): + assert control_plane_report(self) == { + 'queries': {'system': {'timeout': {'client-side-ms': 2000, + 'server-side-ms': 2000}}}, + 'schema': {'agreement': {'timeout-ms': 10000}}, + } + + def test_client_side_timeout(self): + report = control_plane_report(self, control_connection_timeout=4.5) + + assert report['queries']['system']['timeout']['client-side-ms'] == 4500 + + def test_client_side_timeout_is_left_out_when_disabled(self): + report = control_plane_report(self, control_connection_timeout=0) + + assert 'client-side-ms' not in report['queries']['system']['timeout'] + + def test_server_side_timeout_defaults_to_the_client_side_one(self): + """ + Which is what the Cluster does with it when it is not given one. + """ + report = control_plane_report(self, control_connection_timeout=3) + + assert report['queries']['system']['timeout'] == {'client-side-ms': 3000, + 'server-side-ms': 3000} + + def test_server_side_timeout(self): + report = control_plane_report(self, metadata_request_timeout=8) + + assert report['queries']['system']['timeout']['server-side-ms'] == 8000 + + def test_the_server_side_timeout_is_the_clause_the_driver_sends(self): + """ + This one value does not go through the usual conversion. What reaches + the server is whatever maybe_add_timeout_to_query builds, and that + divides a timedelta into whole milliseconds, truncating, and appends no + clause at all when it comes to zero. Rounding up or promoting a + sub-millisecond value -- as every other duration here is -- would report + a limit the server is never given. + + Checked against the builder rather than against numbers written out + here, so the two cannot drift apart. + """ + for seconds in (0.0004, 0.0006, 0.001, 0.0016, 0.002, 0.0025, 1.005, 2, 0): + statement = maybe_add_timeout_to_query( + 'SELECT 1', datetime.timedelta(seconds=seconds)) + sent = (int(statement.split('USING TIMEOUT ')[1][:-2]) + if 'USING TIMEOUT' in statement else None) + + timeout = control_plane_report( + self, metadata_request_timeout=seconds)['queries']['system']['timeout'] + + assert timeout.get('server-side-ms') == sent, seconds + + def test_a_negative_server_side_timeout_is_left_out(self): + """ + The builder does append it, but the clause is malformed and the server + rejects it, and server-side-ms is a positiveInteger with nowhere to put + a negative. + """ + timeout = control_plane_report( + self, metadata_request_timeout=-0.005)['queries']['system']['timeout'] + + assert 'server-side-ms' not in timeout + + def test_no_server_side_timeout_against_a_non_scylla_node(self): + """ + USING TIMEOUT is a ScyllaDB extension, so elsewhere the driver does not + append it and there is no server-side limit to report. The report + describes what the driver will do, not only what it was configured to. + """ + report = control_plane_report(self, is_scylla=False, metadata_request_timeout=8) + + assert 'server-side-ms' not in report['queries']['system']['timeout'] + # The client-side timeout is the driver's own and applies regardless. + assert 'client-side-ms' in report['queries']['system']['timeout'] + + def test_no_server_side_timeout_when_disabled(self): + """ + Zero means the driver appends no USING TIMEOUT and the server's own + default applies, so there is no limit of the driver's to report. + """ + report = control_plane_report(self, metadata_request_timeout=0) + + assert 'server-side-ms' not in report['queries']['system']['timeout'] + + def test_both_timeouts_can_be_absent(self): + """ + The group stays, since the schema requires it; it is the timeouts inside + that are optional. + """ + report = control_plane_report(self, control_connection_timeout=0, + metadata_request_timeout=0) + + assert report['queries'] == {'system': {'timeout': {}}} + + def test_schema_agreement_timeout(self): + report = control_plane_report(self, max_schema_agreement_wait=25) + + assert report['schema']['agreement']['timeout-ms'] == 25000 + + def test_a_sub_millisecond_wait_is_still_a_wait(self): + """ + _wait_for_schema_agreement bypasses agreement only for a timeout of zero + or less, so a sub-millisecond wait is one the driver really takes. + Truncating it to zero would report the bypass instead. + """ + report = control_plane_report(self, max_schema_agreement_wait=0.0004) + + assert report['schema']['agreement']['timeout-ms'] == 1 + + def test_not_waiting_for_schema_agreement_is_a_value(self): + """ + nonNegativeInteger: zero says the driver does not wait, which is a + setting rather than the absence of one, so the key stays. + """ + report = control_plane_report(self, max_schema_agreement_wait=0) + + assert report['schema']['agreement']['timeout-ms'] == 0 + + def test_a_wait_the_schema_cannot_express_drops_the_report(self): + """ + The one that stays a failure. schema.agreement.timeout-ms is required, + so a wait of no describable length has no conformant document to appear + in and the report is dropped -- deliberately, and with a message that + says which kind of value did it. + """ + cluster = Cluster(max_schema_agreement_wait=float('inf')) + self.addCleanup(cluster.shutdown) + + with pytest.raises(ValueError, match='cannot be reported'): + cluster._driver_config_reporter._build_report(cluster, is_scylla=True) + + options = {} + cluster._driver_config_reporter.add_startup_options(options, is_scylla=True) + assert DRIVER_CONFIG_OPTION not in options From 7b05850b6411cc136799a9086b499316513c95a5 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Tue, 25 Aug 2026 14:12:53 +0200 Subject: [PATCH 164/200] DRIVER-379: Report the query group What the driver does with a statement that overrides none of it: the defaults it applies, how a failure is retried, which node it goes to, and whether a slow one is raced. Reported from the default execution profile. The schema has one query group and this driver has as many profiles as the application defines, so the one that describes the session is the one a statement gets when it names none -- which covers legacy configuration too, since Cluster folds a load_balancing_policy or default_retry_policy given to its constructor into that same profile. Other profiles cannot be described under this schema version; worth raising for v2. The built-in retry policies all subclass RetryPolicy, so dispatch is on the exact type: isinstance would report every one of them as the standard policy. ExponentialBackoffRetryPolicy has no arm of its own, but it retries what the standard policy retries and adds a growing delay, which is what the schema's backoff describes. The schema's built-in load balancing arm carries flags describing where a request may go, so it is claimed only when the chain holds a token-aware policy and every policy in it is one this driver can account for. A transparent wrapper above the token-aware policy does not disqualify the chain; a policy whose routing cannot be seen from here does, however ordinary the policy wrapping it -- WhiteListRoundRobinPolicy and HostFilterPolicy each confine routing to a subset the flags have nowhere to record. The datacenter preference is reported either way, found wherever in the policy chain it is set: it says where requests go, not which policy sends them, and a bare DCAwareRoundRobinPolicy -- what the driver falls back to without the murmur3 extension -- pins the client just as firmly as a token-aware one wrapping it. A load balancer of None is resolved the way ResponseFuture resolves it -- `load_balancer or _default_load_balancing_policy` -- so legacy mode with none set reports the policy a request routes with rather than the None. One that nothing resolves raises instead of being reported: a request takes make_query_plan off None and raises too, policy is a required key so there is no conformant document to be had, and reporting a custom policy would tell an operator a user-supplied one is routing. Both of the durations this group adds are left out when they are not finite, which the schema lets it do: speculative-execution because a delay that is never due starts no additional execution, and retry backoff because a retry scheduled that far out is one the driver never reaches -- backoff being optional for exactly the case where there is no delay to describe. Three of the defaults are not the profile's. Paging and client timestamps are Session settings and no Session exists when the control connection reports, so what is described is the default every Session starts with. Idempotence has no configurable default at all, so it is always false. A custom timestamp generator leaves client-timestamps out entirely: it may return None for some requests, leaving the coordinator to assign the timestamp after all, and there is no telling from here which it will do. With this group the report is conformant, which the tests now assert against the vendored schema -- including one that gives a custom policy a password and asserts it appears nowhere in the report. Co-Authored-By: Claude Opus 5 (1M context) --- cassandra/driver_config.py | 744 ++++++++++++++++- tests/unit/test_driver_config.py | 1346 +++++++++++++++++++++++++++++- 2 files changed, 2072 insertions(+), 18 deletions(-) diff --git a/cassandra/driver_config.py b/cassandra/driver_config.py index 8376eb8a58..e02fd41a0c 100644 --- a/cassandra/driver_config.py +++ b/cassandra/driver_config.py @@ -26,10 +26,22 @@ import socket import struct import weakref +from collections import namedtuple from itertools import repeat +from cassandra import ConsistencyLevel from cassandra.policies import (ConstantReconnectionPolicy, - ExponentialReconnectionPolicy) + ConstantSpeculativeExecutionPolicy, + DCAwareRoundRobinPolicy, + DowngradingConsistencyRetryPolicy, + ExponentialBackoffRetryPolicy, + ExponentialReconnectionPolicy, + FallthroughRetryPolicy, NeverRetryPolicy, + NoSpeculativeExecutionPolicy, + DefaultLoadBalancingPolicy, + RackAwareRoundRobinPolicy, RetryPolicy, + RoundRobinPolicy, TokenAwarePolicy) +from cassandra.timestamps import MonotonicTimestampGenerator log = logging.getLogger(__name__) @@ -250,6 +262,37 @@ def _non_negative_ms(seconds): return ms +def _consistency_name(level, setting): + """ + The schema's name for a consistency level. + + The schema takes the name and this driver holds the wire integer, and its + enum covers every level the driver defines, so any level that came from + :class:`~.ConsistencyLevel` maps. One that did not is not a level the driver + can use either -- ``None`` fails to pack into a request at all, and an + unknown integer is rejected by the server -- so there is nothing truthful to + report for it, and a report that named one anyway would tell an operator + that a client which cannot execute a query is querying at that level. + + Raising drops the whole report, which is the right outcome: `consistency` is + a required key, so no conformant document describes such a configuration. + The message names the setting, since the alternative is a bare KeyError + under a generic "unable to build the report" warning. + + A level that cannot be a dict key at all -- a list, say -- fails the lookup + with a TypeError rather than a KeyError, and is the same kind of wrong: it is + caught here too, so that it gets the message naming the setting instead of + the generic warning this one exists to avoid. + """ + try: + return ConsistencyLevel.value_to_name[level] + except (KeyError, TypeError): + raise ValueError( + "%s is %r, which is not a consistency level this driver defines; " + "the configuration report describes the consistency a client uses " + "and cannot describe one it cannot use" % (setting, level)) from None + + _SOCKET_FLAGS = ( ('tcp-no-delay', socket.IPPROTO_TCP, socket.TCP_NODELAY), ('keep-alive', socket.SOL_SOCKET, socket.SO_KEEPALIVE), @@ -389,22 +432,42 @@ def _socket_report(sockopts): return report +def _integer_ceiling(limit): + """ + `limit` rounded up to a builtin ``int``, or ``None`` when no integer can + express it. + + ``math.ceil`` rather than a check against particular numeric types: a limit + is compared against a counter, and anything that can say what its ceiling is + can be counted against one. The result is coerced to a builtin ``int`` so + that no other numeric type reaches the report, where the schema wants an + integer, and so that a limit of ``True`` does not travel on as JSON true + where a number belongs. + + ``None`` is for the limits arithmetic cannot name: ``float('inf')``, which is + how an application spells "without limit" and which the policies really do + accept, ``nan``, and anything that is not a number at all. Every caller has + its own way of saying that a limit is not one the report can carry, and none + of them may raise -- this runs while a connection is being established, and + one unnameable limit must not cost the report every other group it would + have carried. + """ + try: + return int(math.ceil(limit)) + except (TypeError, ValueError, OverflowError): + return None + + def _attempt_ceiling(limit): """ How many attempts a ``while i < limit`` loop makes, or ``None`` when `limit` - does not bound one. - - ``math.ceil`` rather than a check against particular numeric types: the loop - compares against anything an integer can be compared with, and this counts - anything that can say what its ceiling is. The result is coerced to a builtin - ``int`` so that no other numeric type reaches the report, where the schema - wants an integer. + does not bound one -- because it admits no attempt at all, or because it is + not a limit any integer can express. """ if limit is None: return None - try: - attempts = int(math.ceil(limit)) - except (TypeError, ValueError, OverflowError): + attempts = _integer_ceiling(limit) + if attempts is None: return None return attempts if attempts > 0 else None @@ -579,6 +642,520 @@ def _custom_policy_report(policy): return {'type': 'custom', 'name': type(policy).__name__} +_RETRY_POLICY_TYPES = { + # Exact types, not a base class: every one of the others below is a subclass + # of RetryPolicy, so isinstance would report all of them as the first entry. + RetryPolicy: 'standard-error-aware', + FallthroughRetryPolicy: 'fallthrough', + NeverRetryPolicy: 'never', + DowngradingConsistencyRetryPolicy: 'downgrading-consistency', +} + + +def _retry_report(policy, setting): + """ + The ``query.retry`` group: the policy, and the delay between attempts where + the policy has one. + + A policy of ``None`` is not the fallthrough it looks like. That arm means + the driver rethrows the original error to the caller untouched, and what + actually happens is that ResponseFuture calls ``on_request_error`` on it + and raises AttributeError -- losing the original error rather than passing + it on. Naming it fallthrough would describe a working configuration where + there is a broken one, and `policy` is a required key, so there is no + conformant document to be had either. ExecutionProfile replaces a None its + constructor is given, but both it and Cluster.default_retry_policy stay + writable and unvalidated, so this is reachable by assignment. + """ + if policy is None: + raise ValueError( + "%s is None, which is not a retry policy the driver can use: a " + "request error raises AttributeError on it rather than being " + "retried or passed on, and the configuration report describes what " + "a client does" % (setting,)) + + policy_type = _RETRY_POLICY_TYPES.get(type(policy)) + if policy_type is not None: + return {'policy': {'type': policy_type}} + + if type(policy) is ExponentialBackoffRetryPolicy: + # It retries the same errors as the standard policy and adds a growing + # delay between attempts, which is what the schema's backoff describes, + # so it is that policy with a backoff rather than a type of its own. + report = {'policy': {'type': 'standard-error-aware'}} + # Every on_* method gives up once retry_num reaches max_num_retries, and + # the comparison is `<`, so a fractional limit permits the ceiling: 0.5 + # allows one retry. Truncating would report that as no retries at all, + # which is what the schema reads a zero as. The attribute is typed float, + # so fractions are expected -- and so is float('inf'), which is how an + # application says "retry until the request runs out of time". No integer + # names that one, and the key is absent when no explicit limit is + # configured, which is the closest true thing the schema can say about + # it. A negative limit retries nothing, which is what a zero says. + max_retries = _integer_ceiling(policy.max_num_retries) + if max_retries is not None: + report['policy']['max-retries'] = max(0, max_retries) + # Only when there is a delay to describe. _calculate_backoff is + # min(max_interval, min_interval * 2 ** attempt) plus jitter scaled by + # min_interval, so a min_interval of zero is zero at every attempt + # whatever max_interval says. The schema leaves backoff out for exactly + # that -- "absent when there is no delay between attempts" -- and every + # delay it does carry must be greater than zero. + # A non-finite interval is left out with them. _calculate_backoff + # returns that interval or something built from it, and a retry + # scheduled at an infinite delay is one the driver never gets to -- + # there is no delay here the schema can carry, and backoff is optional + # for precisely the case where there is none to describe. + if policy.min_interval > 0 and _finite(policy.min_interval) and _finite(policy.max_interval): + # The initial delay is min(max_interval, min_interval), not + # min_interval: _calculate_backoff caps the whole curve at + # max_interval, and the policy does not check that the two were + # given the right way round. Reporting min_interval would claim a + # first delay the policy never waits whenever max_interval is the + # smaller. Taking the minimum also keeps the schema's requirement + # that max-ms be at least base-ms true by construction. + base_ms = _required_ms(min(policy.min_interval, policy.max_interval)) + report['backoff'] = {'type': 'exponential', + 'base-ms': base_ms, + 'max-ms': _required_ms(policy.max_interval)} + return report + + return {'policy': _custom_policy_report(policy)} + + +_MAX_POLICY_CHAIN = 1024 +""" +Backstop on how far to follow ``_child_policy`` looking for the policy that +holds the location preference. + +The walk stops on its own once it reaches a policy it has already seen, which is +what a chain looping back on itself does, so this is not what ends an ordinary +walk. It is here for the one case identity cannot catch: a ``_child_policy`` +implemented as a property that manufactures a new object on each access, where +every step looks like somewhere new. This runs while a connection is being +established, and a walk that never ends would hang the handshake -- the one +thing this module must never do. + +Set far above any chain an application would build, since stopping early is not +free: the walk reports no location preference at all, which reads as a client +pinned to nothing rather than one whose preference sits deeper than the walk +went, and the chain falls back to the custom arm because what is below the cut +cannot be accounted for. The deepest chain in :mod:`cassandra.policies` is three. +""" + + +_TRUNCATED = object() +""" +Stands in :func:`_policy_chain`'s walk for the part of a chain the cap stopped it +reaching. + +Yielded rather than returned, so that a caller reading the walk one link at a +time cannot miss it: what is below the cut is by definition unaccounted for, and +a survey that answered "every policy in this chain is one I know" about a chain +it stopped walking would put the built-in arm's routing flags against links it +never saw. +""" + + +_DESCRIBABLE_LOAD_BALANCING_POLICIES = ( + TokenAwarePolicy, + DCAwareRoundRobinPolicy, + RackAwareRoundRobinPolicy, + RoundRobinPolicy, + # Delegates every decision to its child bar one: it puts a query's + # target_host first when the statement sets one, which is a per-request + # choice rather than a property of the configuration this describes. + DefaultLoadBalancingPolicy, +) +"""Policies whose routing the token-aware flags can describe. + +Everything else makes the chain undescribable, however ordinary the policy +wrapping it. WhiteListRoundRobinPolicy confines routing to a fixed host list and +HostFilterPolicy to whatever an application-supplied predicate admits; neither +has anywhere to go in the built-in arm, so reporting that arm would assert +plain token-aware routing and say nothing of the restriction. + +Exact types, as everywhere else here: a subclass is a policy this module knows +nothing about, and WhiteListRoundRobinPolicy -- a RoundRobinPolicy subclass that +is emphatically not one -- is why that matters. +""" + + +def _policy_chain(policy): + """ + Each policy from `policy` down through ``_child_policy``. + + Stops on reaching a policy it has already seen, which is what a chain + looping back on itself does. Identity rather than equality, since a custom + policy is free to define __eq__ and compare equal to a different policy, or + to define it without __hash__ and not go into a set at all. Each policy is + held on to as well, so that its id cannot be reused by one created later in + the walk and read as a loop that is not there. + + Running out of :const:`_MAX_POLICY_CHAIN` with a chain still to go yields + :const:`_TRUNCATED` last, which a chain that ended on its own never does. A + caller cannot otherwise tell the two apart, and the difference is whether the + links it saw are the whole chain. + """ + seen, pinned = set(), [] + for _ in range(_MAX_POLICY_CHAIN): + if policy is None or id(policy) in seen: + return + yield policy + seen.add(id(policy)) + pinned.append(policy) + policy = getattr(policy, '_child_policy', None) + if policy is not None and id(policy) not in seen: + yield _TRUNCATED + + +_PolicyChainSurvey = namedtuple('_PolicyChainSurvey', 'located token_aware describable') + + +def _survey_policy_chain(policy): + """ + Everything the load balancing group needs to know about a chain, from a + single walk of it: the policy carrying the location preference, the + token-aware policy, and whether every policy in it is one this module can + account for. + + One walk rather than one per question, for two reasons. + + It bounds the work. The walk is capped at :const:`_MAX_POLICY_CHAIN`, and the + case that cap exists for -- a ``_child_policy`` that manufactures a new + object on each access -- costs that many policy objects every time the chain + is walked, while a connection is being established. + + And it makes the answers describe the same chain. A ``_child_policy`` that + returns something different on each access hands a different chain to each + walk, so separate walks disagree: one finds a token-aware policy where the + next finds none. The report would then combine a preference found in one + chain with an arm decided from another. + + The token-aware policy is looked for anywhere in the chain rather than only + at the top, since a transparent wrapper above it does not stop the routing + being token aware. So is the location preference: under this schema it + belongs to the session rather than to the policy that happens to hold it, and + a bare :class:`~.DCAwareRoundRobinPolicy` -- what + :func:`cassandra.cluster.default_lbp_factory` returns without the murmur3 + extension -- pins the driver to a datacenter just as firmly as a token-aware + policy wrapping one. + """ + located = token_aware = None + describable = True + for link in _policy_chain(policy): + if link is _TRUNCATED: + # The cap cut the walk short, so the rest of the chain is as unknown + # as an application-supplied policy is, and for the same reason: the + # flags below describe the routing of the whole chain, and there is + # more of it than this walk saw. Whatever preference was found above + # the cut still holds, since a policy deeper down cannot take it + # back -- only add one this walk never reaches. + describable = False + break + kind = type(link) + if token_aware is None and kind is TokenAwarePolicy: + token_aware = link + if located is None and kind in (DCAwareRoundRobinPolicy, RackAwareRoundRobinPolicy): + located = link + if kind not in _DESCRIBABLE_LOAD_BALANCING_POLICIES: + # The built-in arm's flags describe the routing of the chain, not of + # the policy at the top of it, so they can only be filled in when + # every policy in it is one this module knows. A chain reaching an + # application-supplied policy is reported as custom even with a + # driver policy wrapping it: the flags would otherwise assert + # something about query plans this code cannot see. + describable = False + return _PolicyChainSurvey(located, token_aware, describable) + + +def _node_location_preference_report(policy): + """ + The ``node-preference`` value describing which datacenter, and possibly + which rack, the driver prefers. + + Sourced from the load balancing policy, which is where this driver keeps it; + the schema asks for it here in that case rather than in a group of its own. + Takes the location-aware policy :func:`_survey_policy_chain` found, which is + ``None`` when the chain holds none at all. + """ + if type(policy) is DCAwareRoundRobinPolicy: + if policy._local_dc_explicit: + return {'type': 'dc', 'local-dc': policy.local_dc} + # Inferred from the first host to come up, and not necessarily known + # yet: the schema allows local-dc to be absent until it is. + report = {'type': 'dc-auto'} + if policy.local_dc: + report['local-dc'] = policy.local_dc + return report + + if type(policy) is RackAwareRoundRobinPolicy: + # Both are mandatory constructor arguments and are never reassigned, so + # they are configured rather than inferred whenever they are set at all. + if policy.local_dc and policy.local_rack: + return {'type': 'rack', 'local-dc': policy.local_dc, + 'local-rack': policy.local_rack} + if policy.local_dc: + return {'type': 'dc', 'local-dc': policy.local_dc} + + return None + + +def _falls_back_to_non_preferred_nodes(located, node_preference): + """ + Whether a request may reach a node outside the reported ``node-preference``. + + Defined against what was reported rather than against the policy type, + because that is how the schema defines it, and because a rack-aware policy + whose rack is unset reports a datacenter preference and has to be judged as + one. + + A rack preference always allows it. ``RackAwareRoundRobinPolicy``'s query + plan yields the local datacenter's other racks straight after the local-rack + tier, unconditionally -- ``used_hosts_per_remote_dc`` gates only the remote + datacenters below that -- so a request routinely reaches a node the reported + preference excludes. ``used_hosts_per_remote_dc`` of zero would otherwise + report the opposite. The schema's single boolean cannot say "leaves the rack + but not the datacenter", and of the two answers this is the true one. + + Only ``rack``, and not the schema's ``rack-auto``: + :func:`_node_location_preference_report` never reports the latter, because + ``RackAwareRoundRobinPolicy`` takes both the datacenter and the rack as + mandatory constructor arguments and never infers either. Testing for it here + would read as though this driver produces it somewhere. + + A datacenter preference allows it only once the policy is told how many + remote hosts to use, since both datacenter-aware policies ignore them + entirely until then. + + No preference at all does not, and not because such a chain keeps requests + anywhere -- a round-robin policy treats every host as local and will happily + reach a remote datacenter. It reports false because it declares no + preference for a request to fall outside of, and no node-preference is + reported for it either, which is what this flag is defined against. The + other ScyllaDB drivers do not all answer this the same way, so it is a + deliberate choice rather than the only reading. + """ + if node_preference is None: + return False + if node_preference['type'] == 'rack': + return True + return bool(getattr(located, 'used_hosts_per_remote_dc', 0)) + + +def _load_balancing_report(policy): + """ + The ``query.load-balancing`` group. + + The built-in ``token-aware`` arm carries flags describing where a request may + go, so it is claimed only when the chain holds a token-aware policy *and* + every policy in it is one this module can account for: see + :func:`_survey_policy_chain`. A transparent + wrapper above the token-aware policy does not disqualify the chain, since it + does not change where requests go; a policy whose routing this module cannot + see does, however ordinary the policy wrapping it. Everything else is a + policy the shared vocabulary has no terms for, and is reported by name -- + the plain round-robin policies among them, built in to this driver but not + token-aware. + + The datacenter preference is reported either way. It is a sibling of the + policy in the schema rather than a property of the built-in arm, and a + policy this module has no name for still pins the driver somewhere the + operator has to be able to see, so it is taken from wherever in the chain + :func:`_survey_policy_chain` found it. + + No policy at all is not a custom one, whatever the chain walk makes of it. + The caller resolves a load balancer of ``None`` the way ResponseFuture does, + so reaching here with one means nothing resolved it: a request takes + ``make_query_plan`` off ``None`` and raises. Reporting a custom policy would + tell an operator a user-supplied one is routing, and `policy` is a required + key, so there is no conformant document to be had either. + """ + if policy is None: + raise ValueError( + "load_balancing_policy is None, which is not a policy the driver can " + "route with: a request raises AttributeError on it, and the " + "configuration report describes what a client does") + + survey = _survey_policy_chain(policy) + located = survey.located + # Built first: the fallback flag below is defined against what this reports. + node_preference = _node_location_preference_report(located) + + token_aware = survey.token_aware + if token_aware is not None and survey.describable: + report = { + 'policy': { + 'type': 'token-aware', + # Replicas are yielded in a random order unless that is turned + # off, in which case they keep the order the replica set has. + 'load-distribution': ('shuffle' if token_aware.shuffle_replicas + else 'replica-set'), + 'fallback-to-non-preferred-nodes': _falls_back_to_non_preferred_nodes( + located, node_preference), + }, + } + else: + # Named after the policy the application configured, which is the one at + # the top of the chain rather than whichever link made it undescribable. + report = {'policy': _custom_policy_report(policy)} + + if node_preference is not None: + report['node-preference'] = node_preference + return report + + +def _default_fetch_size(): + """ + The default page size, or ``None`` when paging is not limited by default. + + Read off the :class:`~.Session` class rather than an instance: paging is a + session setting in this driver, and no session exists yet when the control + connection reports. What this describes is the default every session created + from that cluster will start with, which is the closest thing to a + cluster-wide answer there is; a session that then sets its own + ``default_fetch_size`` is not reflected here. + + Imported where it is used, since :mod:`cassandra.cluster` imports this + module. + """ + from cassandra.cluster import Session + + # operator.index rather than a check against int: a page size is packed into + # the request as an integer, which takes anything with __index__, so a numpy + # integer paginates exactly as a builtin one does and describing it as + # unlimited would be wrong. It also returns a builtin int, which keeps a + # page size of True out of the report as JSON true where a number belongs. + try: + fetch_size = operator.index(Session.default_fetch_size) + except TypeError: + return None + return fetch_size if fetch_size > 0 else None + + +def _client_timestamps(timestamp_generator): + """ + Whether the client assigns the write timestamp, or ``None`` when that cannot + be answered. + + Two things decide it. :attr:`.Session.use_client_timestamp` gates whether + the generator is consulted at all -- with it off the coordinator assigns + every timestamp, whatever generator the cluster holds. It is read off the + class for the same reason as :func:`_default_fetch_size`: it is a session + setting, and no session exists yet when the control connection reports. + + Then a custom generator is the schema's "unknown": it is called per request + and may return None for some of them, in which case the coordinator assigns + the timestamp after all, and there is no way to tell from here which it will + do. + + So is no generator at all, with the setting left on. That is not the + coordinator assigning timestamps, which is what a False here says to the + operator reading it: Session._create_response_future calls + self.cluster.timestamp_generator() unconditionally under this setting, so + every request raises a TypeError instead. Neither answer is true of such a + cluster, and unlike the consistency level or the retry policy this key is + optional -- so it is left out, rather than the whole report dropped over a + configuration the driver will not get a query out of anyway. + """ + from cassandra.cluster import Session + + if not Session.use_client_timestamp: + return False + if timestamp_generator is None: + return None + if type(timestamp_generator) is MonotonicTimestampGenerator: + return True + return None + + +def _speculative_delay_ms(delay): + """ + The delay before each additional execution in milliseconds, or ``None`` when + no execution will ever be started with it. + + A negative delay starts nothing: ``next_execution()`` hands the configured + delay straight through, and + :meth:`cassandra.cluster.ResponseFuture._start_timer` creates the + speculative timer only for a delay of zero or more. It is also the very + value the plan returns once it has run out, so the driver cannot tell a + negative delay from an exhausted plan. + + A delay that cannot be compared with zero at all starts nothing either, and + takes the request with it: that comparison is ``_start_timer``'s, and it + raises there. :class:`~.ConstantSpeculativeExecutionPolicy` validates its + arguments no more than it validates the rest, so both are reachable. + + Neither has a value the group can carry -- ``delay-ms`` is a + ``nonNegativeInteger`` -- which is why both come back as the absence the + caller turns into an absent group. + + A delay that is not finite starts nothing either. _start_timer schedules at + that delay and an infinite one comes due at no moment the timer ever + reaches, while a nan sorts against no deadline at all -- so no additional + execution is launched, and the absent group says exactly that. + """ + try: + if delay < 0: + return None + except TypeError: + return None + if not _finite(delay): + return None + return _non_negative_ms(delay) + + +def _speculative_execution_report(policy): + """ + The ``query.speculative-execution`` group, or ``None`` when the driver will + not start a duplicate execution -- which the schema expresses by leaving the + group out rather than by a policy that does nothing. + """ + if policy is None or type(policy) is NoSpeculativeExecutionPolicy: + return None + + if type(policy) is ConstantSpeculativeExecutionPolicy: + # The delay decides first, because an unusable one starts nothing + # whatever the count says -- including a count of float('inf'), which + # otherwise reaches the custom arm below and reports a policy that races + # as often as it likes while _start_timer never makes it a timer at all. + # No usable delay: see _speculative_delay_ms. + delay_ms = _speculative_delay_ms(policy.delay) + if delay_ms is None: + return None + + # The plan counts `remaining` down while it is above zero, so a + # fractional limit yields the ceiling here too: 0.5 admits one execution + # and 1.5 admits two. + max_executions = _integer_ceiling(policy.max_attempts) + if max_executions is None: + # A limit no integer can express, float('inf') being the one an + # application would reach for to keep racing for as long as the + # request lives. The plan counts down from it and never runs out, so + # the driver does speculate, and leaving the group out would say it + # never does. max-executions is a required positiveInteger with no + # way to say "without limit", so the arm for a policy the shared + # vocabulary cannot describe is the only truthful one left -- as it + # is for a limit that is not a number at all, which is a policy that + # raises when it builds its plan. + return {'policy': _custom_policy_report(policy)} + + if max_executions < 1: + # The other way to configure a policy that never races anything, and + # the group cannot describe it from the inside either: + # max-executions is a required positiveInteger with no way to say + # "none", so absence is how the schema says it -- exactly as for the + # no-op policy above. The plan's next_execution() returns -1 from the + # very first call. + return None + + return {'policy': {'type': 'constant', + 'max-executions': max_executions, + 'delay-ms': delay_ms}} + + return {'policy': _custom_policy_report(policy)} + + class DriverConfigReporter: """ Builds the :const:`DRIVER_CONFIG_OPTION` ``STARTUP`` option describing the @@ -661,6 +1238,7 @@ def _populate_report(self, report, cluster, is_scylla): """ report['connection'] = self._connection_report(cluster) report['control-plane'] = self._control_plane_report(cluster, is_scylla) + report['query'] = self._query_report(cluster) def _connection_report(self, cluster): """ @@ -745,6 +1323,150 @@ def _connection_report(self, cluster): report['tls'] = tls return report + def _query_report(self, cluster): + """ + The ``query`` group: what the driver does with a statement that does not + override any of it. + + Reported from the default execution profile. The schema has one query + group and this driver has as many profiles as the application cares to + define, so the one that describes the session is the one a statement + gets when it names none. Profiles other than the default cannot be + described under this schema version. + + Which of the two configuration modes is live decides where the policies + and the defaults come from, and the profile is not always the answer. + Assigning + :attr:`~.Cluster.default_retry_policy` or + :attr:`~.Cluster.load_balancing_policy` after construction switches the + cluster to legacy mode and updates only the cluster attribute, leaving + the default profile holding whatever it was built with. A request then + takes the cluster's, so reading the profile would describe policies + nothing will ever use. Given to the constructor instead, the two agree, + because the profile is built from those same attributes. + + This is the choice + :meth:`cassandra.cluster.Session._create_response_future` makes for + every request, and + :meth:`cassandra.cluster.ControlConnection._try_connect_to_hosts` for + its own connections. Imported where it is used, since + :mod:`cassandra.cluster` imports this module. + """ + from cassandra.cluster import _ConfigMode + + profile = cluster.profile_manager.default + legacy = cluster._config_mode == _ConfigMode.LEGACY + + report = { + 'defaults': self._query_defaults_report(cluster, profile, legacy), + 'retry': _retry_report( + cluster.default_retry_policy if legacy else profile.retry_policy, + 'default_retry_policy' if legacy else 'retry_policy'), + # ResponseFuture resolves a load balancer of None to the default + # profile's policy -- `load_balancer or _default_load_balancing_policy` + # -- so in legacy mode with none set that is what a request routes + # with, and reporting the None would describe a policy nothing uses. + 'load-balancing': _load_balancing_report( + (cluster.load_balancing_policy if legacy else profile.load_balancing_policy) + or cluster._default_load_balancing_policy), + } + + # Legacy configuration races nothing, whatever the profile holds: the + # legacy branch of _create_response_future leaves its speculative + # execution plan unset, so there is no group to report. + speculative_execution = None if legacy else _speculative_execution_report( + profile.speculative_execution_policy) + if speculative_execution is not None: + report['speculative-execution'] = speculative_execution + return report + + def _query_defaults_report(self, cluster, profile, legacy): + """ + The ``query.defaults`` group. + + A snapshot taken before any :class:`~.Session` exists, so the settings + this driver keeps on the session rather than on the profile are read off + the :class:`~.Session` class -- ``default_fetch_size`` and + ``use_client_timestamp`` always, and the three below in legacy + configuration mode. What that describes is the default every session + created from this cluster will start with, which is the closest thing to + a cluster-wide answer there is; a session that then sets its own is not + reflected here. + + Which of the two configuration modes is live decides where the + consistency, the serial consistency and the request timeout come from, + the same way it does for the policies in :meth:`_query_report`. The + legacy branch of + :meth:`cassandra.cluster.Session._create_response_future` reads + ``default_consistency_level``, ``default_serial_consistency_level`` and + ``default_timeout`` off the session and never looks at the profile, so + reading the profile there would describe a consistency nothing will ever + query at: the profile is built holding + :attr:`.ExecutionProfile.consistency_level`'s own default rather than the + session's. Under profiles the profile is the answer. + """ + from cassandra.cluster import Session + + if legacy: + consistency = Session._default_consistency_level + serial_consistency = Session._default_serial_consistency_level + request_timeout = Session._default_timeout + consistency_setting, serial_setting = ('default_consistency_level', + 'default_serial_consistency_level') + else: + consistency = profile.consistency_level + serial_consistency = profile.serial_consistency_level + request_timeout = profile.request_timeout + consistency_setting, serial_setting = ('consistency_level', + 'serial_consistency_level') + + report = { + 'consistency': _consistency_name(consistency, consistency_setting), + # This driver has no configurable default: Statement.is_idempotent + # is False unless a statement says otherwise, and nothing at cluster + # or profile level changes that. + 'idempotence': False, + } + + # Unset means the server's own default applies, which is not this + # driver's to describe. A level that is not a serial one is not this + # driver's to describe either: ExecutionProfile validates the argument + # its constructor is given and leaves the attribute writable, and + # Session.default_serial_consistency_level's setter validates every + # assignment, so a non-serial level is reachable through the profile + # alone. The schema's enum here is the two serial levels, so naming one + # would put a value in the field no consumer has to accept -- and a + # non-serial level is not one a conditional statement can use anyway. + if serial_consistency is not None: + if ConsistencyLevel.is_serial(serial_consistency): + report['serial-consistency'] = _consistency_name( + serial_consistency, serial_setting) + else: + # Warned rather than passed over: absence in this field means + # the server's default applies, which is not what is happening, + # and the key being optional is the only reason this does not + # take the whole report down the way an unnameable consistency + # does. + log.warning("%s is %r, which is not a serial consistency level; " + "it will be left out of the driver configuration report", + serial_setting, serial_consistency) + + request_timeout_ms = _optional_ms(request_timeout) + if request_timeout_ms is not None: + report['request'] = {'timeout-ms': request_timeout_ms} + + # Paging is a Session setting rather than a profile one, and no Session + # exists yet when the control connection reports: this is the default + # every Session created from now on will start with. + page_size = _default_fetch_size() + if page_size is not None: + report['page'] = {'size': page_size} + + client_timestamps = _client_timestamps(cluster.timestamp_generator) + if client_timestamps is not None: + report['client-timestamps'] = client_timestamps + return report + def _control_plane_report(self, cluster, is_scylla): """ The ``control-plane`` group: the timeouts on the driver's own queries, diff --git a/tests/unit/test_driver_config.py b/tests/unit/test_driver_config.py index f249b6c944..bba4306c81 100644 --- a/tests/unit/test_driver_config.py +++ b/tests/unit/test_driver_config.py @@ -22,20 +22,42 @@ import ssl import struct import unittest +import uuid +import warnings +from io import BytesIO from unittest import mock from unittest.mock import Mock import numpy import pytest -from cassandra.cluster import Cluster +from cassandra import ConsistencyLevel +from cassandra.cluster import Cluster, EXEC_PROFILE_DEFAULT, ExecutionProfile, Session from cassandra.driver_config import (DriverConfigReporter, DRIVER_CONFIG_OPTION, DRIVER_CONFIG_SCHEMA_VERSION, MAX_DRIVER_CONFIG_LENGTH, - _non_negative_ms, _optional_ms, _reconnection_policy_report, - _required_ms, _socket_report) + _load_balancing_report, _non_negative_ms, _optional_ms, + _MAX_POLICY_CHAIN, + _node_location_preference_report, + _survey_policy_chain, + _reconnection_policy_report, + _required_ms, _retry_report, + _socket_report, _speculative_execution_report) +from cassandra.connection import DefaultEndPoint +from cassandra.pool import Host +from cassandra.protocol import QueryMessage from cassandra.util import maybe_add_timeout_to_query -from cassandra.policies import (ConstantReconnectionPolicy, ExponentialReconnectionPolicy, - ReconnectionPolicy) +from cassandra.policies import (ConstantReconnectionPolicy, ConstantSpeculativeExecutionPolicy, + DCAwareRoundRobinPolicy, DowngradingConsistencyRetryPolicy, + ExponentialBackoffRetryPolicy, ExponentialReconnectionPolicy, + FallthroughRetryPolicy, NeverRetryPolicy, + NoSpeculativeExecutionPlan, NoSpeculativeExecutionPolicy, + DefaultLoadBalancingPolicy, + RackAwareRoundRobinPolicy, ReconnectionPolicy, RetryPolicy, + SimpleConvictionPolicy, + HostFilterPolicy, RoundRobinPolicy, + SpeculativeExecutionPolicy, TokenAwarePolicy, + WhiteListRoundRobinPolicy) +from tests.driver_config_schema import load_schema, validate_report from tests.unit.utils import _ClusterlessReporter, ThrowingReporter @@ -101,10 +123,10 @@ def reporter(test, **cluster_kwargs): return report_cluster(test, **cluster_kwargs)._driver_config_reporter -def report_text(test, **cluster_kwargs): +def report_text(test, is_scylla=True, **cluster_kwargs): """The report of a real Cluster, as it goes on the wire.""" built = report_cluster(test, **cluster_kwargs) - return built._driver_config_reporter._build_report(built, is_scylla=True) + return built._driver_config_reporter._build_report(built, is_scylla=is_scylla) class DriverConfigReporterTest(unittest.TestCase): @@ -1245,3 +1267,1313 @@ def test_a_wait_the_schema_cannot_express_drops_the_report(self): options = {} cluster._driver_config_reporter.add_startup_options(options, is_scylla=True) assert DRIVER_CONFIG_OPTION not in options + + +def full_report(test, is_scylla=True, **cluster_kwargs): + """ + The parsed report, validated on the way through. + + Every configuration any test here builds is one this driver may really send, + so each of them is a conformance case too -- cheaper and harder to forget + than adding one to ReportConformsToTheSchemaTest by hand. + """ + return validate_report(report_text(test, is_scylla=is_scylla, **cluster_kwargs)) + + +def query_report(test, profile=None, **cluster_kwargs): + if profile is not None: + cluster_kwargs['execution_profiles'] = {EXEC_PROFILE_DEFAULT: profile} + return full_report(test, **cluster_kwargs)['query'] + + +class QueryDefaultsTest(unittest.TestCase): + def test_defaults(self): + assert query_report(self)['defaults'] == { + 'consistency': 'LOCAL_ONE', + 'idempotence': False, + 'request': {'timeout-ms': 10000}, + 'page': {'size': 5000}, + 'client-timestamps': True, + } + + def test_consistency_is_reported_by_name(self): + """ + The wire form is an integer and the schema wants the name. + """ + report = query_report(self, ExecutionProfile(consistency_level=ConsistencyLevel.QUORUM)) + + assert report['defaults']['consistency'] == 'QUORUM' + + def test_every_consistency_level_has_a_name_the_schema_accepts(self): + """ + The driver's levels and the schema's enum have to stay in step: a level + this driver has and the schema does not would be reported and rejected. + """ + schema = load_schema() + accepted = schema['$defs']['query-defaults']['properties']['consistency']['enum'] + + assert set(ConsistencyLevel.value_to_name.values()) == set(accepted) + + def test_serial_consistency(self): + report = query_report(self, ExecutionProfile( + serial_consistency_level=ConsistencyLevel.LOCAL_SERIAL)) + + assert report['defaults']['serial-consistency'] == 'LOCAL_SERIAL' + + def test_serial_consistency_is_left_out_when_unset(self): + """ + Unset means the server's own default applies, which is not this driver's + to describe. + """ + assert 'serial-consistency' not in query_report(self)['defaults'] + + def test_a_level_that_is_not_serial_is_left_out_and_warned_about(self): + """ + ExecutionProfile validates the argument its constructor is given and + leaves the attribute writable, so a non-serial level is reachable. The + schema's enum here is the two serial levels, and naming one anyway is + the only way a live Cluster could produce a document the shared contract + rejects. + + Warned rather than passed over: absence in this field means the server's + default applies, which is not what is happening, and the key being + optional is the only reason this does not take the whole report down the + way an unnameable consistency does. + """ + profile = ExecutionProfile() + cluster = Cluster(execution_profiles={EXEC_PROFILE_DEFAULT: profile}) + self.addCleanup(cluster.shutdown) + profile.serial_consistency_level = ConsistencyLevel.QUORUM + + with self.assertLogs('cassandra.driver_config', level='WARNING') as captured: + report = validate_report( + cluster._driver_config_reporter._build_report(cluster, is_scylla=True)) + + assert 'serial-consistency' not in report['query']['defaults'] + assert 'serial_consistency_level is 4' in '\n'.join( + r.getMessage() for r in captured.records) + + def test_request_timeout(self): + report = query_report(self, ExecutionProfile(request_timeout=2.5)) + + assert report['defaults']['request'] == {'timeout-ms': 2500} + + def test_request_timeout_is_left_out_when_disabled(self): + report = query_report(self, ExecutionProfile(request_timeout=None)) + + assert 'request' not in report['defaults'] + + def test_idempotence_is_always_false(self): + """ + This driver has no configurable default: a statement is not idempotent + unless it says so, and nothing at cluster or profile level changes that. + """ + assert query_report(self)['defaults']['idempotence'] is False + + def test_a_page_size_of_any_integer_type_is_reported(self): + """ + A page size is packed into the request as an integer, which takes + anything with __index__, so a numpy integer paginates exactly as a + builtin one does -- reporting nothing for it says paging is unlimited. + A bool is one row per page, and is reported as the number 1, since the + schema wants an integer and JSON true is not one. + """ + for value, expected in ((numpy.int64(123), 123), (True, 1), (5000, 5000)): + with mock.patch.object(Session, 'default_fetch_size', value): + report = query_report(self)['defaults'] + + assert report['page'] == {'size': expected}, value + assert type(report['page']['size']) is int, value + + def test_a_page_size_that_limits_nothing_is_left_out(self): + for value in (None, 0, 2.5): + with mock.patch.object(Session, 'default_fetch_size', value): + report = query_report(self)['defaults'] + + assert 'page' not in report, value + + def test_client_timestamps(self): + """ + The default generator assigns the timestamp client-side. + """ + assert query_report(self)['defaults']['client-timestamps'] is True + + def test_client_timestamps_are_off_when_the_session_does_not_use_them(self): + """ + use_client_timestamp gates whether the generator is consulted at all, so + with it off the coordinator assigns every timestamp whatever generator + the cluster holds. Read off the class for the same reason as the page + size: it is a session setting, and no session exists yet. + """ + with mock.patch.object(Session, 'use_client_timestamp', False): + report = query_report(self) + + assert report['defaults']['client-timestamps'] is False + + def test_client_timestamps_are_unknown_with_a_custom_generator(self): + """ + A custom generator is called per request and may return None for some of + them, leaving the coordinator to assign the timestamp after all. The + schema's way of saying that is to leave the key out. + """ + report = query_report(self, timestamp_generator=lambda: 1234) + + assert 'client-timestamps' not in report['defaults'] + + def test_client_timestamps_are_unknown_with_no_generator_at_all(self): + """ + Reachable by assignment, since the constructor substitutes the default + for a None. Reported as unknown rather than False, which would say the + coordinator assigns them: Session._create_response_future calls the + generator unconditionally under this setting, so what really happens is + that every request raises a TypeError. The rest of the report still goes + out, because this key is optional. + """ + built = Cluster() + self.addCleanup(built.shutdown) + built.timestamp_generator = None + + report = validate_report( + built._driver_config_reporter._build_report(built, is_scylla=True)) + + assert 'client-timestamps' not in report['query']['defaults'] + assert report['query']['defaults']['consistency'] == 'LOCAL_ONE' + + +class RetryReportTest(unittest.TestCase): + def test_built_in_policies(self): + for policy, expected in ( + (RetryPolicy(), 'standard-error-aware'), + (FallthroughRetryPolicy(), 'fallthrough'), + (NeverRetryPolicy(), 'never'), + (DowngradingConsistencyRetryPolicy(), 'downgrading-consistency')): + assert _retry_report(policy, 'retry_policy') == {'policy': {'type': expected}}, policy + + def test_no_policy_is_not_a_fallthrough(self): + """ + The fallthrough arm means the driver rethrows the original error to the + caller untouched. With no policy at all, ResponseFuture calls + on_request_error on None and raises AttributeError instead, losing the + original error -- so naming it fallthrough would describe a working + configuration where there is a broken one. + + ExecutionProfile replaces a None its constructor is given, but both it + and Cluster.default_retry_policy stay writable, so this is reachable. + """ + profile = ExecutionProfile() + assert profile.retry_policy is not None # the constructor replaced it + profile.retry_policy = None # but nothing stops this + + with pytest.raises(AttributeError): + profile.retry_policy.on_request_error(None, 1, error=None, retry_num=0) + + with pytest.raises(ValueError, match='retry_policy is None'): + _retry_report(None, 'retry_policy') + + def test_a_report_is_dropped_rather_than_naming_a_policy_that_is_not_used(self): + """ + policy is a required key, so there is no conformant document for such a + configuration -- the whole report goes, as it does for a consistency + level the driver cannot name. + """ + cluster = Cluster() + self.addCleanup(cluster.shutdown) + cluster.profile_manager.default.retry_policy = None + options = {} + + cluster._driver_config_reporter.add_startup_options(options, is_scylla=True) + + assert DRIVER_CONFIG_OPTION not in options + + def test_dispatch_is_on_the_exact_type(self): + """ + Every built-in above is a subclass of RetryPolicy, so isinstance would + report all of them as the standard policy. This is the mistake the + mapping is most likely to make. + """ + assert _retry_report(FallthroughRetryPolicy(), 'retry_policy')['policy']['type'] == 'fallthrough' + + class Tweaked(FallthroughRetryPolicy): + pass + + assert _retry_report(Tweaked(), 'retry_policy')['policy'] == {'type': 'custom', 'name': 'Tweaked'} + + def test_exponential_backoff_is_the_standard_policy_with_a_backoff(self): + """ + It retries what the standard policy retries and adds a growing delay, + which is what the schema's backoff describes. + """ + report = _retry_report(ExponentialBackoffRetryPolicy( + max_num_retries=4, min_interval=0.2, max_interval=5.0), 'retry_policy') + + assert report == { + 'policy': {'type': 'standard-error-aware', 'max-retries': 4}, + 'backoff': {'type': 'exponential', 'base-ms': 200, 'max-ms': 5000}, + } + + def test_intervals_given_the_wrong_way_round(self): + """ + _calculate_backoff caps the whole curve at max_interval, so the initial + delay is min(max_interval, min_interval) and not min_interval. The policy + does not check the order, so reporting min_interval would claim a first + delay it never waits. That also keeps the schema's requirement that + max-ms be at least base-ms true by construction. + """ + policy = ExponentialBackoffRetryPolicy( + max_num_retries=1, min_interval=10.0, max_interval=1.0) + # The un-jittered curve is flat at max_interval, never at min_interval. + assert [min(1.0, 10.0 * 2 ** a) for a in range(4)] == [1.0, 1.0, 1.0, 1.0] + + report = _retry_report(policy, 'retry_policy') + + assert report['backoff']['base-ms'] == 1000 + assert report['backoff']['max-ms'] == 1000 + + def test_a_backoff_maximum_is_never_below_its_base(self): + for mi, mx in ((10.0, 1.0), (0.1, 10.0), (5.0, 5.0), (0.0004, 0.0009)): + backoff = _retry_report( + ExponentialBackoffRetryPolicy(1, mi, mx), 'retry_policy')['backoff'] + assert backoff['max-ms'] >= backoff['base-ms'], (mi, mx) + + def test_a_backoff_that_never_delays_is_left_out(self): + """ + _calculate_backoff is min(max_interval, min_interval * 2 ** attempt) + plus jitter scaled by min_interval, so a min_interval of zero is zero at + every attempt whatever max_interval says. The schema leaves backoff out + for exactly that, and rejects a delay of zero inside it. + """ + policy = ExponentialBackoffRetryPolicy(3, min_interval=0, max_interval=10.0) + assert [policy._calculate_backoff(a) for a in range(4)] == [0, 0, 0, 0] + + report = _retry_report(policy, 'retry_policy') + + assert 'backoff' not in report + # The policy itself is still described, retries and all. + assert report['policy'] == {'type': 'standard-error-aware', 'max-retries': 3} + + def test_no_retries_is_a_value(self): + """ + max-retries is a nonNegativeInteger whose zero the schema spells "no + retries", so unlike the counts elsewhere in the report this one says + what it means and needs no special case. + """ + assert ExponentialBackoffRetryPolicy(0, 0.1, 1.0).on_read_timeout( + None, 1, 1, 1, False, 0)[0] == RetryPolicy.RETHROW + assert _retry_report( + ExponentialBackoffRetryPolicy(0, 0.1, 1.0), 'retry_policy')['policy']['max-retries'] == 0 + # Negative counts mean the same thing and cannot be reported as such. + assert _retry_report( + ExponentialBackoffRetryPolicy(-2, 0.1, 1.0), 'retry_policy')['policy']['max-retries'] == 0 + + def test_a_fractional_retry_limit_rounds_up(self): + """ + Every on_* method gives up once retry_num reaches max_num_retries, and + the comparison is `<`, so 0.5 still permits one retry. Truncating + reports zero, which the schema reads as no retries at all -- the + opposite of what the policy does. The attribute is typed float, so + fractions are an expected input rather than an abuse. + """ + for limit, retries in ((0.5, 1), (1.5, 2), (2.5, 3)): + policy = ExponentialBackoffRetryPolicy(limit, 0.1, 1.0) + permitted = sum( + policy.on_read_timeout(None, 1, 1, 1, False, n)[0] == RetryPolicy.RETRY + for n in range(10)) + assert permitted == retries, limit + + assert _retry_report(policy, 'retry_policy')['policy']['max-retries'] == retries, limit + + def test_a_retry_limit_no_integer_can_express_leaves_the_key_out(self): + """ + max_num_retries is typed float, so float('inf') is how an application + says "retry until the request runs out of time" -- and the policy really + does honour it, since every on_* method only ever compares against it. + No integer names that limit, and an absent max-retries is the schema's + way of saying none was configured, which is the closest true thing. + """ + policy = ExponentialBackoffRetryPolicy(float('inf'), 0.1, 1.0) + assert all(policy.on_read_timeout(None, 1, 1, 1, False, n)[0] == RetryPolicy.RETRY + for n in range(100)) + + assert 'max-retries' not in _retry_report(policy, 'retry_policy')['policy'] + assert _retry_report(policy, 'retry_policy')['policy']['type'] == 'standard-error-aware' + + def test_an_unnameable_retry_limit_does_not_cost_the_rest_of_the_report(self): + """ + The regression this guards: math.ceil raises OverflowError on inf and + TypeError on anything that is not a number, and one unnameable limit + used to take every other group down with it -- the connection settings, + the control-plane timeouts, all of it. + """ + for limit in (float('inf'), float('nan'), None, 'lots'): + report = full_report(self, execution_profiles={EXEC_PROFILE_DEFAULT: ExecutionProfile( + retry_policy=ExponentialBackoffRetryPolicy(limit, 0.1, 1.0))}) + + assert 'max-retries' not in report['query']['retry']['policy'], limit + assert report['connection']['connect']['timeout-ms'] == 5000, limit + + def test_a_custom_policy_is_named_and_nothing_more(self): + class SecretiveRetryPolicy(RetryPolicy): + def __init__(self): + self.password = 'hunter2' + + assert _retry_report(SecretiveRetryPolicy(), 'retry_policy') == { + 'policy': {'type': 'custom', 'name': 'SecretiveRetryPolicy'}} + + def test_a_backoff_interval_that_is_not_finite_is_left_out(self): + """ + backoff is optional, and is already left out when there is no delay to + describe. An interval that is not finite is the same case one step on: + _calculate_backoff returns it or something built from it, and a retry + scheduled that far out is one the driver never reaches. The policy + itself is still reported -- only the delay is undescribable. + """ + for interval in (float('inf'), float('nan')): + report = _retry_report( + ExponentialBackoffRetryPolicy(3, interval, interval), + 'default_retry_policy') + assert 'backoff' not in report, interval + assert report['policy']['type'] == 'standard-error-aware', interval + + +class LoadBalancingReportTest(unittest.TestCase): + def test_token_aware_over_a_datacenter_aware_child(self): + report = _load_balancing_report(TokenAwarePolicy(DCAwareRoundRobinPolicy('dc1'))) + + assert report == { + 'policy': {'type': 'token-aware', 'load-distribution': 'shuffle', + 'fallback-to-non-preferred-nodes': False}, + 'node-preference': {'type': 'dc', 'local-dc': 'dc1'}, + } + + def test_load_distribution_follows_replica_shuffling(self): + policy = TokenAwarePolicy(DCAwareRoundRobinPolicy('dc1'), shuffle_replicas=False) + + assert _load_balancing_report(policy)['policy']['load-distribution'] == 'replica-set' + + def test_fallback_to_non_preferred_nodes(self): + """ + The datacenter-aware policies ignore remote hosts entirely until they + are told how many to use. + """ + policy = TokenAwarePolicy(DCAwareRoundRobinPolicy('dc1', used_hosts_per_remote_dc=2)) + + assert _load_balancing_report(policy)['policy']['fallback-to-non-preferred-nodes'] is True + + def test_only_the_preferences_this_driver_reports_are_handled(self): + """ + The schema has a rack-auto arm and this driver never produces it: + RackAwareRoundRobinPolicy takes both the datacenter and the rack as + mandatory constructor arguments and never infers either. Pinned so that + the flag's handling stays matched to what is actually reported. + """ + class Host: + datacenter, rack, endpoint = 'inferred', 'r', 'e' + + inferred = DCAwareRoundRobinPolicy() + inferred.on_up(Host()) + + emitted = set() + for policy in (DCAwareRoundRobinPolicy(), DCAwareRoundRobinPolicy('dc1'), + DCAwareRoundRobinPolicy(''), inferred, + RackAwareRoundRobinPolicy('dc1', 'rack1'), + RackAwareRoundRobinPolicy('dc1', ''), + RackAwareRoundRobinPolicy('', 'rack1'), + RackAwareRoundRobinPolicy('', ''), + RoundRobinPolicy(), None): + reported = _node_location_preference_report(policy) + emitted.add(reported['type'] if reported else None) + + assert emitted == {'dc', 'dc-auto', 'rack', None} + + def test_a_rack_preference_always_falls_back(self): + """ + RackAwareRoundRobinPolicy's query plan yields the local datacenter's + other racks straight after the local-rack tier, unconditionally -- + used_hosts_per_remote_dc gates only the remote datacenters below that. + So a request routinely reaches a node the reported rack preference + excludes, whatever that setting says. + """ + for remote in (0, 2): + policy = TokenAwarePolicy( + RackAwareRoundRobinPolicy('dc1', 'rack1', used_hosts_per_remote_dc=remote)) + report = _load_balancing_report(policy) + + assert report['node-preference']['type'] == 'rack' + assert report['policy']['fallback-to-non-preferred-nodes'] is True, remote + + def test_the_rack_tier_really_is_unconditional(self): + """ + The premise of the test above, read off the policy rather than assumed: + with no remote hosts allowed, a host in the local datacenter but another + rack is still in the query plan. + """ + policy = RackAwareRoundRobinPolicy('dc1', 'rack1', used_hosts_per_remote_dc=0) + local = Host(DefaultEndPoint(1), SimpleConvictionPolicy, 'dc1', 'rack1', + host_id=uuid.uuid4()) + other_rack = Host(DefaultEndPoint(2), SimpleConvictionPolicy, 'dc1', 'rack2', + host_id=uuid.uuid4()) + policy.populate(Mock(), [local, other_rack]) + + assert other_rack in list(policy.make_query_plan()) + + def test_a_rack_aware_policy_without_a_rack_is_judged_as_a_datacenter_one(self): + """ + It reports a datacenter preference, so the flag has to be answered + against that: other racks are inside the preference, not outside it. + """ + for remote, expected in ((0, False), (2, True)): + policy = TokenAwarePolicy( + RackAwareRoundRobinPolicy('dc1', '', used_hosts_per_remote_dc=remote)) + report = _load_balancing_report(policy) + + assert report['node-preference']['type'] == 'dc' + assert report['policy']['fallback-to-non-preferred-nodes'] is expected, remote + + def test_no_preference_means_nothing_to_fall_outside_of(self): + """ + Not because such a chain keeps requests anywhere -- round robin treats + every host as local and will happily reach a remote datacenter. It + reports false because it declares no preference for a request to fall + outside of, and no node-preference is reported for it either, which is + what the flag is defined against. The other ScyllaDB drivers do not all + answer this the same way, so it is a deliberate choice. + """ + report = _load_balancing_report(TokenAwarePolicy(RoundRobinPolicy())) + + assert 'node-preference' not in report + assert report['policy']['fallback-to-non-preferred-nodes'] is False + + def test_an_inferred_datacenter(self): + """ + Not yet known at report time is a state the schema allows for, and the + one the first control connection is usually in. + """ + policy = TokenAwarePolicy(DCAwareRoundRobinPolicy()) + + assert _load_balancing_report(policy)['node-preference'] == {'type': 'dc-auto'} + + def test_an_inferred_datacenter_once_it_is_known(self): + child = DCAwareRoundRobinPolicy() + # Driven through on_up, which is what infers: assigning local_dc is the + # application choosing one, and is reported as such. + child.on_up(Host(DefaultEndPoint(1), SimpleConvictionPolicy, 'inferred-dc', + host_id=uuid.uuid4())) + + report = _load_balancing_report(TokenAwarePolicy(child)) + + assert report['node-preference'] == {'type': 'dc-auto', 'local-dc': 'inferred-dc'} + + def test_the_datacenter_cannot_be_reassigned(self): + """ + local_dc is read-only, so a configured datacenter and an inferred one + cannot be confused: an assignment afterwards would be indistinguishable + from on_up's inference, and telling them apart is the whole point of the + dc / dc-auto distinction. + """ + policy = DCAwareRoundRobinPolicy('dc1') + + with pytest.raises(AttributeError): + policy.local_dc = 'dc2' + + assert policy.local_dc == 'dc1' + assert _node_location_preference_report(policy) == {'type': 'dc', 'local-dc': 'dc1'} + + def test_inference_still_fills_in_an_unset_datacenter(self): + """ + The other half: read-only to the application, still filled in by on_up + when the constructor was given nothing -- and reported as inferred. + """ + policy = DCAwareRoundRobinPolicy() + assert _node_location_preference_report(policy) == {'type': 'dc-auto'} + + policy.on_up(Host(DefaultEndPoint(1), SimpleConvictionPolicy, 'inferred', + host_id=uuid.uuid4())) + + assert policy.local_dc == 'inferred' + assert _node_location_preference_report(policy) == { + 'type': 'dc-auto', 'local-dc': 'inferred'} + + def test_a_rack_aware_child(self): + policy = TokenAwarePolicy(RackAwareRoundRobinPolicy('dc1', 'rack1')) + + assert _load_balancing_report(policy)['node-preference'] == { + 'type': 'rack', 'local-dc': 'dc1', 'local-rack': 'rack1'} + + def test_no_policy_is_resolved_the_way_a_request_resolves_it(self): + """ + ResponseFuture takes `load_balancer or _default_load_balancing_policy`, + so a legacy cluster with none set routes with the default profile's + policy. Reporting the None as a custom policy would tell an operator a + user-supplied one is routing when the driver's own is. + """ + with warnings.catch_warnings(): + warnings.simplefilter('ignore', DeprecationWarning) + cluster = Cluster() + self.addCleanup(cluster.shutdown) + cluster.load_balancing_policy = None + + report = json.loads( + cluster._driver_config_reporter._build_report(cluster, is_scylla=True)) + + assert report['query']['load-balancing']['policy']['type'] == 'token-aware' + assert type(cluster._default_load_balancing_policy) is TokenAwarePolicy + + def test_a_policy_nothing_resolves_is_not_a_custom_one(self): + """ + When the fallback is None too, a request takes make_query_plan off None + and raises. policy is a required key, so the whole report goes -- as it + does for a retry policy of None, which crashes the same way. + """ + cluster = Cluster(execution_profiles={ + EXEC_PROFILE_DEFAULT: ExecutionProfile(load_balancing_policy=None)}) + self.addCleanup(cluster.shutdown) + + assert cluster._default_load_balancing_policy is None + with pytest.raises(ValueError, match='load_balancing_policy is None'): + _load_balancing_report(None) + + options = {} + cluster._driver_config_reporter.add_startup_options(options, is_scylla=True) + assert DRIVER_CONFIG_OPTION not in options + + def test_policies_that_are_not_token_aware_are_custom(self): + """ + Only the token-aware policy maps onto the schema's built-in arm. The + round-robin policies are built in to this driver but are not token + aware, and the shared vocabulary has no term for them. + """ + for policy in (RoundRobinPolicy(), DCAwareRoundRobinPolicy('dc1'), + WhiteListRoundRobinPolicy([])): + report = _load_balancing_report(policy) + assert report['policy'] == {'type': 'custom', + 'name': type(policy).__name__}, policy + + def test_a_custom_policy_still_reports_its_datacenter(self): + """ + The preference is a sibling of the policy in the schema, not a property + of the built-in arm. A bare DCAwareRoundRobinPolicy -- which is what + default_lbp_factory() returns without the murmur3 extension -- pins the + driver to a datacenter just as firmly as a token-aware one wrapping it, + and an operator cannot tell that from the type name alone. + """ + report = _load_balancing_report(DCAwareRoundRobinPolicy('dc1')) + + assert report == { + 'policy': {'type': 'custom', 'name': 'DCAwareRoundRobinPolicy'}, + 'node-preference': {'type': 'dc', 'local-dc': 'dc1'}, + } + + def test_a_bare_rack_aware_policy_reports_its_rack(self): + """ + RackAwareRoundRobinPolicy takes both as mandatory arguments, so the most + deliberate pinning an application can express is also the one most + likely to be used without a token-aware wrapper. + """ + report = _load_balancing_report(RackAwareRoundRobinPolicy('dc1', 'rack1')) + + assert report == { + 'policy': {'type': 'custom', 'name': 'RackAwareRoundRobinPolicy'}, + 'node-preference': {'type': 'rack', 'local-dc': 'dc1', + 'local-rack': 'rack1'}, + } + + def test_the_preference_is_reported_even_for_an_undescribable_chain(self): + """ + node-preference is a sibling of the policy rather than part of it, so a + chain the built-in arm cannot describe still says where the driver is + pinned. The policy itself goes to the custom arm: HostFilterPolicy + admits only what an application-supplied predicate allows, which the + token-aware flags have nowhere to record. + """ + policy = TokenAwarePolicy( + HostFilterPolicy(DCAwareRoundRobinPolicy('dc1', used_hosts_per_remote_dc=2), + lambda host: True)) + + report = _load_balancing_report(policy) + + assert report['node-preference'] == {'type': 'dc', 'local-dc': 'dc1'} + assert report['policy'] == {'type': 'custom', 'name': 'TokenAwarePolicy'} + + def test_a_chain_reaching_an_unknown_policy_is_custom(self): + """ + The built-in arm's flags describe the routing of the whole chain, so + they can only be filled in when every policy in it is one this module + knows. Reporting them over an unknown child would assert plain + token-aware routing and say nothing of what the child does -- + WhiteListRoundRobinPolicy confines routing to a fixed host list, and a + RoundRobinPolicy subclass at that, which is why the check is on exact + types. + """ + class MyCustomPolicy(RoundRobinPolicy): + pass + + for child in (WhiteListRoundRobinPolicy([]), + HostFilterPolicy(RoundRobinPolicy(), lambda host: True), + MyCustomPolicy()): + report = _load_balancing_report(TokenAwarePolicy(child)) + + assert report['policy'] == {'type': 'custom', 'name': 'TokenAwarePolicy'}, child + + def test_token_awareness_is_found_under_a_transparent_wrapper(self): + """ + A wrapper above the token-aware policy does not stop the routing being + token aware, so the arm is claimed from anywhere in the chain. + """ + policy = DefaultLoadBalancingPolicy( + TokenAwarePolicy(DCAwareRoundRobinPolicy('dc1'))) + + report = _load_balancing_report(policy) + + assert report['policy']['type'] == 'token-aware' + assert report['node-preference'] == {'type': 'dc', 'local-dc': 'dc1'} + + def test_no_preference_when_nothing_in_the_chain_is_location_aware(self): + for policy in (RoundRobinPolicy(), TokenAwarePolicy(RoundRobinPolicy())): + assert 'node-preference' not in _load_balancing_report(policy), policy + + def test_the_preference_is_found_however_deep_it_sits(self): + """ + Stopping the walk early is not free: it reports no location preference + at all, which reads as a client pinned to nothing rather than one whose + preference sits deeper than the walk went. Nothing about a wrapper + changes where requests go, so depth must not decide what is reported. + """ + for depth in (1, 7, 8, 12, 200): + policy = DCAwareRoundRobinPolicy('dc1') + for _ in range(depth): + policy = HostFilterPolicy(policy, lambda host: True) + + report = _load_balancing_report(TokenAwarePolicy(policy)) + + assert report['node-preference'] == {'type': 'dc', 'local-dc': 'dc1'}, depth + + def test_a_self_referential_chain_terminates(self): + """ + The walk stops once it reaches a policy it has already seen, which is + what a chain looping back on itself does. This runs while a connection + is being established, and a walk that never ends would hang the + handshake. + """ + policy = HostFilterPolicy(RoundRobinPolicy(), lambda host: True) + policy._child_policy = policy + + assert 'node-preference' not in _load_balancing_report(policy) + + def test_the_chain_is_walked_once(self): + """ + The group needs three answers about a chain, and taking them from + separate walks costs the walk over again -- _MAX_POLICY_CHAIN policy + objects each, for the chain that bound exists for, while a connection is + being established. + + It also lets the answers describe different chains: a _child_policy + returning something different on each access hands each walk its own, + so one can find a token-aware policy where the next finds none. + """ + built = [] + + class Endless(RoundRobinPolicy): + @property + def _child_policy(self): + built.append(None) + return Endless() + + _load_balancing_report(Endless()) + + assert len(built) == _MAX_POLICY_CHAIN + + def test_a_chain_that_manufactures_children_terminates(self): + """ + The case identity cannot catch, and what the backstop is for: every + access returns a new object, so no step is ever somewhere the walk has + been before. + """ + class EndlessPolicy(RoundRobinPolicy): + @property + def _child_policy(self): + return EndlessPolicy() + + assert _survey_policy_chain(EndlessPolicy()).located is None + + def test_a_chain_the_walk_could_not_finish_is_custom(self): + """ + The flags on the built-in arm describe the routing of the whole chain, so + a chain the cap cut short cannot claim them: what is below the cut is as + unaccounted for as an application-supplied policy is. Reporting + token-aware here would assert shuffling and remote fallback for links the + walk never reached. + + Every link the walk does see is a policy this module accounts for, which + is what makes the cap the only thing that can decide it. A chain of + subclasses would come out custom whether the cut were noticed or not. + """ + policy = TokenAwarePolicy(RoundRobinPolicy()) + + with mock.patch.object(RoundRobinPolicy, '_child_policy', create=True, + new_callable=mock.PropertyMock) as child: + child.side_effect = RoundRobinPolicy + + report = _load_balancing_report(policy) + + assert report['policy'] == {'type': 'custom', 'name': 'TokenAwarePolicy'} + + def test_a_chain_that_ends_within_the_cap_is_not_treated_as_cut_short(self): + """ + The other side of it: the sentinel must be the exhausted walk only, or + every ordinary chain would report as custom. + """ + report = _load_balancing_report(TokenAwarePolicy(RoundRobinPolicy())) + + assert report['policy']['type'] == 'token-aware' + + def test_identity_rather_than_equality_decides_a_loop(self): + """ + A custom policy is free to compare equal to a different policy, which + must not read as a chain that loops back on itself. + """ + class EqualToAnything(RoundRobinPolicy): + def __eq__(self, other): + return True + + __hash__ = None # as Python does for anything defining __eq__ + + policy = EqualToAnything() + policy._child_policy = EqualToAnything() + policy._child_policy._child_policy = DCAwareRoundRobinPolicy('dc1') + + assert _survey_policy_chain(policy).located.local_dc == 'dc1' + + def test_a_custom_policy_is_named_and_nothing_more(self): + class SecretiveLoadBalancingPolicy(RoundRobinPolicy): + def __init__(self): + self.password = 'hunter2' + + assert _load_balancing_report(SecretiveLoadBalancingPolicy()) == { + 'policy': {'type': 'custom', 'name': 'SecretiveLoadBalancingPolicy'}} + + +class SpeculativeExecutionReportTest(unittest.TestCase): + def test_absent_by_default(self): + """ + The schema leaves the group out rather than carrying a policy that does + nothing, and doing nothing is this driver's default. + """ + assert _speculative_execution_report(NoSpeculativeExecutionPolicy()) is None + assert _speculative_execution_report(None) is None + assert 'speculative-execution' not in query_report(self) + + def test_constant(self): + report = _speculative_execution_report( + ConstantSpeculativeExecutionPolicy(delay=0.5, max_attempts=3)) + + assert report == {'policy': {'type': 'constant', 'max-executions': 3, + 'delay-ms': 500}} + + def test_launching_immediately_is_a_value(self): + report = _speculative_execution_report( + ConstantSpeculativeExecutionPolicy(delay=0, max_attempts=1)) + + assert report['policy']['delay-ms'] == 0 + + def test_a_policy_that_never_speculates_is_absent_too(self): + """ + max-executions is a required positiveInteger, so the group cannot say + "none" from the inside. A policy configured with no attempts never + speculates -- next_execution() returns -1 from the first call, and + ResponseFuture only schedules a delay of zero or more -- so reporting + one execution would claim a race the driver never runs. + """ + for attempts in (0, -1): + plan = ConstantSpeculativeExecutionPolicy(0.5, attempts).new_plan('ks', None) + assert plan.next_execution('host') == -1, attempts + assert _speculative_execution_report( + ConstantSpeculativeExecutionPolicy(0.5, attempts)) is None, attempts + + def test_a_policy_that_never_speculates_leaves_a_conformant_report(self): + report = validate_report(report_text(self, execution_profiles={ + EXEC_PROFILE_DEFAULT: ExecutionProfile( + speculative_execution_policy=ConstantSpeculativeExecutionPolicy(0.5, 0))})) + + assert 'speculative-execution' not in report['query'] + + def test_a_negative_delay_never_races_anything(self): + """ + next_execution hands the configured delay straight through, and + ResponseFuture._start_timer creates the speculative timer only for a + delay of zero or more. A negative delay is also the very value the plan + returns once it has run out, so the driver cannot tell the two apart -- + neither starts an execution. Reporting the group would claim a race that + never happens, and delay-ms cannot carry the negative anyway. + """ + for delay in (-1, -0.001, -60): + plan = ConstantSpeculativeExecutionPolicy(delay, 5).new_plan('ks', None) + # What _start_timer tests before making a timer. + assert plan.next_execution('host') < 0, delay + + assert _speculative_execution_report( + ConstantSpeculativeExecutionPolicy(delay, 5)) is None, delay + + def test_an_unusable_delay_wins_over_an_unlimited_count(self): + """ + A count no integer can express reaches the custom arm, but only if the + policy races at all. next_execution hands the delay straight through and + _start_timer makes a timer only for zero or more, so a negative delay + starts nothing however many executions were asked for -- reporting the + group would claim a race that never runs. + """ + for delay in (-1, -0.001): + policy = ConstantSpeculativeExecutionPolicy(delay, float('inf')) + plan = policy.new_plan('ks', None) + # What _start_timer tests before making a timer. + assert plan.next_execution('host') < 0, delay + + assert _speculative_execution_report(policy) is None, delay + + # A usable delay with the same count still reaches the custom arm. + assert _speculative_execution_report( + ConstantSpeculativeExecutionPolicy(0.5, float('inf'))) == { + 'policy': {'type': 'custom', + 'name': 'ConstantSpeculativeExecutionPolicy'}} + + def test_a_zero_delay_still_races(self): + """ + The boundary the driver itself draws: zero is scheduled, below it is not. + """ + report = _speculative_execution_report(ConstantSpeculativeExecutionPolicy(0, 2)) + + assert report['policy']['delay-ms'] == 0 + + def test_a_sub_millisecond_delay_is_not_an_immediate_one(self): + """ + Sub-millisecond speculative execution is a real setting for a + low-latency workload, and must not read as "launch immediately". + """ + assert _speculative_execution_report( + ConstantSpeculativeExecutionPolicy(0.0004, 2))['policy']['delay-ms'] == 1 + + def test_a_fractional_execution_limit_rounds_up(self): + """ + The plan counts `remaining` down while it is above zero, so a fractional + limit admits the ceiling. Half an execution is still one, and omitting + the group for it would say speculative execution is disabled when it + runs. + """ + for limit, executions in ((0.5, 1), (1.5, 2), (2.5, 3)): + plan = ConstantSpeculativeExecutionPolicy(0.5, limit).new_plan('ks', None) + launched = 0 + while plan.next_execution('host') >= 0: + launched += 1 + assert launched == executions, limit + + report = _speculative_execution_report( + ConstantSpeculativeExecutionPolicy(0.5, limit)) + assert report['policy']['max-executions'] == executions, limit + + def test_an_execution_limit_no_integer_can_express_is_a_policy_with_no_name(self): + """ + float('inf') is how an application says "keep racing for as long as the + request lives", and the plan honours it: it counts down from inf and + never runs out. max-executions is a required positiveInteger with no way + to say that, and leaving the group out would claim the driver never + speculates when it always does -- so the only truthful arm left is the + one for a policy the shared vocabulary cannot describe. + """ + policy = ConstantSpeculativeExecutionPolicy(0.5, float('inf')) + plan = policy.new_plan('ks', None) + assert all(plan.next_execution('host') >= 0 for _ in range(100)) + + assert _speculative_execution_report(policy) == { + 'policy': {'type': 'custom', 'name': 'ConstantSpeculativeExecutionPolicy'}} + + def test_a_limit_that_is_not_a_number_is_reported_the_same_way(self): + """ + The policy validates nothing, so a limit it will raise on when it builds + its plan is reachable. There is still a policy configured, which is more + than an absent group would say, and it is no more describable than an + unlimited one. + """ + for limit in (None, 'two'): + assert _speculative_execution_report( + ConstantSpeculativeExecutionPolicy(0.5, limit)) == { + 'policy': {'type': 'custom', + 'name': 'ConstantSpeculativeExecutionPolicy'}}, limit + + def test_a_delay_that_cannot_be_compared_never_races_anything(self): + """ + _start_timer is what compares the delay with zero, so a delay that + cannot be compared raises there and no execution is ever started -- the + same outcome as a negative one, and the same absent group. + """ + assert _speculative_execution_report( + ConstantSpeculativeExecutionPolicy(None, 3)) is None + + def test_an_unnameable_policy_does_not_cost_the_rest_of_the_report(self): + """ + As for retries: math.ceil used to raise here and take every other group + of the report with it. + """ + for delay, limit in ((0.5, float('inf')), (0.5, None), (None, 3)): + report = full_report(self, execution_profiles={EXEC_PROFILE_DEFAULT: ExecutionProfile( + speculative_execution_policy=ConstantSpeculativeExecutionPolicy(delay, limit))}) + + assert report['connection']['connect']['timeout-ms'] == 5000, (delay, limit) + + def test_a_custom_policy_is_named_and_nothing_more(self): + class SecretiveSpeculativeExecutionPolicy(SpeculativeExecutionPolicy): + def __init__(self): + self.password = 'hunter2' + + def new_plan(self, keyspace, statement): + return NoSpeculativeExecutionPlan() + + assert _speculative_execution_report(SecretiveSpeculativeExecutionPolicy()) == { + 'policy': {'type': 'custom', 'name': 'SecretiveSpeculativeExecutionPolicy'}} + + def test_a_delay_that_never_comes_due_starts_nothing(self): + """ + _start_timer schedules the additional execution at the configured + delay, and Timer.finish tests `time_now >= self.end` -- never true of an + infinite delay or of a nan. Nothing is ever launched, which the schema + says by leaving the group out, exactly as it does for a delay the timer + refuses outright. + """ + for delay in (float('inf'), float('nan')): + assert _speculative_execution_report( + ConstantSpeculativeExecutionPolicy(delay, 5)) is None, delay + + +class ProfileSourceTest(unittest.TestCase): + def test_the_default_profile_is_what_is_reported(self): + report = query_report(self, ExecutionProfile( + consistency_level=ConsistencyLevel.THREE, + retry_policy=FallthroughRetryPolicy())) + + assert report['defaults']['consistency'] == 'THREE' + assert report['retry']['policy']['type'] == 'fallthrough' + + def test_other_profiles_are_not_reported(self): + """ + The schema has one query group and this driver has as many profiles as + the application defines, so the one a statement gets when it names none + is the one that describes the session. + """ + cluster = Cluster(execution_profiles={ + EXEC_PROFILE_DEFAULT: ExecutionProfile(consistency_level=ConsistencyLevel.ONE), + 'other': ExecutionProfile(consistency_level=ConsistencyLevel.ALL), + }) + self.addCleanup(cluster.shutdown) + + report = json.loads(cluster._driver_config_reporter._build_report(cluster, True)) + + assert report['query']['defaults']['consistency'] == 'ONE' + + def test_policies_assigned_after_construction_are_reported(self): + """ + Assigning either legacy policy switches the cluster to legacy mode and + updates only the cluster attribute; the default profile keeps whatever + it was built with. A request takes the cluster's, so reading the profile + would describe policies nothing will ever use -- here, retries and + token-aware routing that are not going to happen. + """ + with warnings.catch_warnings(): + warnings.simplefilter('ignore', DeprecationWarning) + cluster = Cluster() + self.addCleanup(cluster.shutdown) + cluster.default_retry_policy = FallthroughRetryPolicy() + cluster.load_balancing_policy = RoundRobinPolicy() + + # The profile still holds the construction-time policies, which is + # what makes this worth asserting. + profile = cluster.profile_manager.default + assert type(profile.retry_policy) is RetryPolicy + assert type(profile.load_balancing_policy) is TokenAwarePolicy + + report = json.loads( + cluster._driver_config_reporter._build_report(cluster, is_scylla=True))['query'] + + assert report['retry']['policy'] == {'type': 'fallthrough'} + assert report['load-balancing']['policy'] == {'type': 'custom', + 'name': 'RoundRobinPolicy'} + + def test_legacy_configuration_races_nothing(self): + """ + The legacy branch of _create_response_future leaves the speculative + execution plan unset whatever the profile holds, so there is no group to + report even when a policy was put on the profile by hand. + """ + with warnings.catch_warnings(): + warnings.simplefilter('ignore', DeprecationWarning) + cluster = Cluster() + self.addCleanup(cluster.shutdown) + cluster.default_retry_policy = FallthroughRetryPolicy() + cluster.profile_manager.default.speculative_execution_policy = \ + ConstantSpeculativeExecutionPolicy(0.5, 2) + + report = json.loads( + cluster._driver_config_reporter._build_report(cluster, is_scylla=True))['query'] + + assert 'speculative-execution' not in report + + def test_legacy_configuration_reads_the_same(self): + """ + A load balancing or retry policy given to the Cluster constructor is + folded into the default profile, so both ways of configuring the driver + report identically. + """ + with warnings.catch_warnings(): + warnings.simplefilter('ignore', DeprecationWarning) + report = query_report(self, load_balancing_policy=RoundRobinPolicy(), + default_retry_policy=FallthroughRetryPolicy()) + + assert report['retry']['policy']['type'] == 'fallthrough' + assert report['load-balancing']['policy'] == {'type': 'custom', + 'name': 'RoundRobinPolicy'} + + def test_legacy_defaults_come_from_the_session_not_the_profile(self): + """ + The legacy branch of _create_response_future reads the consistency, the + serial consistency and the timeout off the Session and never looks at + the profile, so the profile's values are ones no request will ever use. + + The two agree by default, which is why this sets the profile away from + them: the default profile is built with Session._default_timeout but + with ExecutionProfile's own consistency default, so a report reading the + profile is wrong about the consistency and right about the timeout by + coincidence. + """ + with warnings.catch_warnings(): + warnings.simplefilter('ignore', DeprecationWarning) + cluster = Cluster(default_retry_policy=FallthroughRetryPolicy()) + self.addCleanup(cluster.shutdown) + profile = cluster.profile_manager.default + profile.consistency_level = ConsistencyLevel.ALL + profile.serial_consistency_level = ConsistencyLevel.SERIAL + profile.request_timeout = 99 + + report = json.loads( + cluster._driver_config_reporter._build_report(cluster, is_scylla=True)) + + defaults = report['query']['defaults'] + assert defaults['consistency'] == 'LOCAL_ONE' + assert 'serial-consistency' not in defaults + assert defaults['request']['timeout-ms'] == 10000 + + def test_legacy_defaults_follow_the_session_class(self): + """ + Read off the class rather than an instance because no Session exists + when the control connection reports: what this describes is the default + every session created from the cluster will start with. + """ + with warnings.catch_warnings(): + warnings.simplefilter('ignore', DeprecationWarning) + with mock.patch.multiple( + Session, + _default_consistency_level=ConsistencyLevel.QUORUM, + _default_serial_consistency_level=ConsistencyLevel.LOCAL_SERIAL, + _default_timeout=42.0): + report = full_report(self, default_retry_policy=FallthroughRetryPolicy()) + + assert report['query']['defaults']['consistency'] == 'QUORUM' + assert report['query']['defaults']['serial-consistency'] == 'LOCAL_SERIAL' + assert report['query']['defaults']['request']['timeout-ms'] == 42000 + + +class ReportConformsToTheSchemaTest(unittest.TestCase): + """ + The point of the whole series: what this driver sends is what the shared + contract says it may send. + """ + + def test_the_default_configuration(self): + for is_scylla in (True, False): + validate_report(report_text(self, is_scylla=is_scylla)) + + def test_a_policy_that_never_reconnects(self): + """ + The schema's null arm, reached from a real configuration rather than + from no policy at all. Asserted here rather than beside the policy + mapping, since validating it needs a whole conformant document and the + report only becomes one with this group. + """ + report = validate_report(report_text( + self, reconnection_policy=ExponentialReconnectionPolicy(1.0, 2.0, max_attempts=0))) + + assert report['connection']['reconnection']['policy'] is None + + def test_a_configuration_that_avoids_every_default(self): + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + + report = report_text( + self, + connect_timeout=1.5, + control_connection_timeout=3, + metadata_request_timeout=4, + max_schema_agreement_wait=0, + reconnection_policy=ConstantReconnectionPolicy(0, max_attempts=9), + shard_aware_options={'disable_shardaware_port': True}, + ssl_context=context, + sockopts=[(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1), + (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1), + (socket.SOL_SOCKET, socket.SO_RCVBUF, 65536), + (socket.SOL_SOCKET, socket.SO_LINGER, struct.pack('ii', 1, 5))], + execution_profiles={EXEC_PROFILE_DEFAULT: ExecutionProfile( + load_balancing_policy=TokenAwarePolicy( + RackAwareRoundRobinPolicy('dc1', 'rack1', used_hosts_per_remote_dc=1), + shuffle_replicas=False), + retry_policy=ExponentialBackoffRetryPolicy(3, 0.1, 2.0), + consistency_level=ConsistencyLevel.EACH_QUORUM, + serial_consistency_level=ConsistencyLevel.SERIAL, + request_timeout=0.0004, + speculative_execution_policy=ConstantSpeculativeExecutionPolicy(0.25, 2), + )}) + + validate_report(report) + + def test_a_configuration_of_nothing_but_custom_policies(self): + class CustomLoadBalancingPolicy(RoundRobinPolicy): + pass + + class CustomRetryPolicy(RetryPolicy): + pass + + class CustomReconnectionPolicy(ReconnectionPolicy): + def new_schedule(self): + return iter(()) + + report = validate_report(report_text( + self, + reconnection_policy=CustomReconnectionPolicy(), + execution_profiles={EXEC_PROFILE_DEFAULT: ExecutionProfile( + load_balancing_policy=CustomLoadBalancingPolicy(), + retry_policy=CustomRetryPolicy())})) + + assert report['query']['load-balancing']['policy']['name'] == 'CustomLoadBalancingPolicy' + assert report['connection']['reconnection']['policy']['name'] == 'CustomReconnectionPolicy' + + def test_a_custom_policy_does_not_leak_its_attributes(self): + """ + The schema permits a custom policy's public attributes to be serialized + and this driver deliberately sends none of them: a policy is an + arbitrary object whose __dict__ is trivially reachable, and whatever it + holds would land in system.clients for anyone who can select from it. + """ + class CredentialCarryingPolicy(RoundRobinPolicy): + def __init__(self): + super().__init__() + self.password = 'hunter2' + self.hosts = ['10.0.0.1', '10.0.0.2'] + + report = report_text(self, execution_profiles={ + EXEC_PROFILE_DEFAULT: ExecutionProfile( + load_balancing_policy=CredentialCarryingPolicy())}) + + assert 'hunter2' not in report + assert '10.0.0.1' not in report + assert json.loads(report)['query']['load-balancing']['policy'] == { + 'type': 'custom', 'name': 'CredentialCarryingPolicy'} + + def test_a_duration_that_is_not_finite_still_leaves_a_report(self): + """ + The cost of one undescribable duration used to be the whole document: + int() raises on inf and on nan, nothing caught it before + add_startup_options, and the operator lost every other group along with + the key that could not be converted. + + These all describe what the driver does with such a setting instead -- + an unbounded timeout as the absence that already means "no limit here", + a delay that never comes due as the reconnection it never performs. + """ + cases = { + 'request_timeout': dict(execution_profiles={ + EXEC_PROFILE_DEFAULT: ExecutionProfile(request_timeout=float('inf'))}), + 'control_connection_timeout': dict(control_connection_timeout=float('inf')), + 'metadata_request_timeout': dict(metadata_request_timeout=float('inf')), + 'connect_timeout': dict(connect_timeout=float('inf')), + 'reconnection_policy': dict( + reconnection_policy=ConstantReconnectionPolicy(float('inf'))), + 'speculative_execution_policy': dict(execution_profiles={ + EXEC_PROFILE_DEFAULT: ExecutionProfile( + speculative_execution_policy=ConstantSpeculativeExecutionPolicy( + float('inf'), 5))}), + } + for setting, kwargs in cases.items(): + report = json.loads(report_text(self, **kwargs)) + validate_report(report) + assert set(report) == {'version', 'connection', 'control-plane', 'query'}, setting + + +class UnnameableConsistencyTest(unittest.TestCase): + """ + ExecutionProfile validates serial_consistency_level but not + consistency_level, so a level the driver does not define can be configured. + """ + + def test_no_working_configuration_is_affected(self): + """ + The premise of dropping the report rather than naming something else: a + level the schema cannot name is one the driver cannot use either. + """ + with pytest.raises(Exception): + QueryMessage(query='SELECT 1', consistency_level=None).send_body(BytesIO(), 4) + + def test_the_report_is_dropped_rather_than_naming_a_level_that_is_not_used(self): + """ + consistency is a required key, so no conformant report describes such a + configuration. Naming the driver's default instead would tell an + operator that a client which cannot execute a query is querying at + LOCAL_ONE. + """ + for level in (None, 99): + options = {} + reporter(self, execution_profiles={ + EXEC_PROFILE_DEFAULT: ExecutionProfile(consistency_level=level) + }).add_startup_options(options, is_scylla=True) + + assert DRIVER_CONFIG_OPTION not in options, level + + def test_the_warning_names_the_setting(self): + """ + Otherwise this surfaces as a bare KeyError under a generic "unable to + build the report", which does not say which setting caused it. + """ + with self.assertLogs('cassandra.driver_config', level='WARNING') as captured: + reporter(self, execution_profiles={ + EXEC_PROFILE_DEFAULT: ExecutionProfile(consistency_level=99) + }).add_startup_options({}, is_scylla=True) + + logged = '\n'.join(r.getMessage() + (r.exc_text or '') for r in captured.records) + assert 'consistency_level is 99' in logged + + def test_a_level_that_cannot_be_a_key_gets_the_same_message(self): + """ + An unhashable level fails the lookup with a TypeError rather than a + KeyError. Same kind of wrong, so it takes the same route: the message + naming the setting, not the generic "unable to build the report" this + one exists to avoid. + """ + with self.assertLogs('cassandra.driver_config', level='WARNING') as captured: + options = {} + reporter(self, execution_profiles={ + EXEC_PROFILE_DEFAULT: ExecutionProfile(consistency_level=[]) + }).add_startup_options(options, is_scylla=True) + + assert DRIVER_CONFIG_OPTION not in options + logged = '\n'.join(r.getMessage() + (r.exc_text or '') for r in captured.records) + assert 'consistency_level is []' in logged + + def test_recognized_levels_are_unaffected(self): + for level in ConsistencyLevel.value_to_name: + report = query_report(self, ExecutionProfile(consistency_level=level)) + assert report['defaults']['consistency'] == ConsistencyLevel.value_to_name[level] + + def test_a_boolean_is_the_level_it_packs_as(self): + """ + True hashes equal to 1 and names ONE, which is not a mismatch: the wire + encoding packs it to the same two bytes, so the client really does query + at ONE. + """ + report = query_report(self, ExecutionProfile(consistency_level=True)) + + assert report['defaults']['consistency'] == 'ONE' From 16d61a057baed37ed5852bb5093be2a9b6e08d8c Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Tue, 25 Aug 2026 14:12:53 +0200 Subject: [PATCH 165/200] DRIVER-379: Cover the populated report end to end The unit tests establish that the reporter builds the right document. These establish that the document reaches the server intact and describes the client that sent it -- which is all an operator reading system.clients has. The existing {"version":1} assertion becomes a schema validation: pinning the document here would duplicate the unit tests and break on every group added to it. The round-trip test sets every setting it checks away from its default, so a report built from the wrong source, or from defaults, fails rather than happening to match. Two of these cannot be unit tests. The server-side timeout is reported only against ScyllaDB, and a unit test can only assert that for a flag it passes in itself; here the detection runs against the SUPPORTED response of an actual node. The inferred datacenter is the other: it is inferred from a host that has to exist. Co-Authored-By: Claude Opus 5 (1M context) --- .../standard/test_driver_config.py | 162 +++++++++++++++++- 1 file changed, 154 insertions(+), 8 deletions(-) diff --git a/tests/integration/standard/test_driver_config.py b/tests/integration/standard/test_driver_config.py index 8728c22b54..13ac3225f4 100644 --- a/tests/integration/standard/test_driver_config.py +++ b/tests/integration/standard/test_driver_config.py @@ -12,12 +12,17 @@ # See the License for the specific language governing permissions and # limitations under the License. -import json +import socket import time import unittest +from cassandra import ConsistencyLevel +from cassandra.cluster import EXEC_PROFILE_DEFAULT, ExecutionProfile from cassandra.driver_config import (DRIVER_CONFIG_OPTION, DRIVER_CONFIG_SCHEMA_VERSION, SESSION_ID_OPTION) +from cassandra.policies import (ConstantReconnectionPolicy, + ConstantSpeculativeExecutionPolicy, FallthroughRetryPolicy) +from tests.driver_config_schema import validate_report from tests.integration import (TestCluster, get_client_options, use_single_node, remove_cluster, xfail_scylla_version_lt) @@ -76,34 +81,89 @@ def _settled_connection_count(session, timeout=CONNECTION_WAIT_TIMEOUT): time.sleep(0.5) -def _wait_for_connections(session, session_id, count, timeout=CONNECTION_WAIT_TIMEOUT): +def _poll_client_options(session, session_id, settled, timeout=CONNECTION_WAIT_TIMEOUT): """ - Polls the clients table until at least ``count`` connections report - ``session_id``, and returns the client options of the ones that do. + Polls the clients table until `settled` accepts the options of the + connections reporting ``session_id``, and returns them however the wait + ended. ``Cluster.connect(wait_for_all_pools=True)`` waits for the pools to be created, but a connection shows up here only once the server has registered it, so the rows arrive later than the connections do. - Returns a short list if the timeout expires first rather than asserting, so - that the count stays the caller's claim to make: an "absent everywhere" or a + Returns whatever it has if the timeout expires first rather than asserting, + so that the claim stays the caller's to make: an "absent everywhere" or a "reported exactly once" holds trivially over a list that is short only because the rows had not appeared yet. """ deadline = time.time() + timeout while True: options = [o for o in get_client_options(session) if o.get(SESSION_ID_OPTION) == session_id] - if len(options) >= count or time.time() >= deadline: + if settled(options) or time.time() >= deadline: return options time.sleep(0.5) +def _wait_for_connections(session, session_id, count, timeout=CONNECTION_WAIT_TIMEOUT): + """ + Polls the clients table until at least ``count`` connections report + ``session_id``, and returns the client options of the ones that do. + """ + return _poll_client_options(session, session_id, + lambda options: len(options) >= count, timeout) + + +def _wait_for_reported_config(session, session_id, timeout=CONNECTION_WAIT_TIMEOUT): + """ + Polls the clients table until one of ``session_id``'s connections is listed + with a configuration report, and returns the client options of all of them. + + Waits on the report rather than on a count of rows, because a count is not + the same predicate. The control connection is the first the driver opens, but + the order rows appear in here is the server's, and on a shard aware cluster + the pool opens a connection per shard while the control connection is still + registering -- so a wait for one row can return one that carries no report, + and a test reading the report off it would fail for the timing rather than + for what it set out to check. + """ + return _poll_client_options( + session, session_id, + lambda options: any(DRIVER_CONFIG_OPTION in o for o in options), timeout) + + def _assert_listed(options, count, session_id, timeout=CONNECTION_WAIT_TIMEOUT): assert len(options) >= count, \ "only %d of %d connections with SESSION_ID %s were listed within %ss" % ( len(options), count, session_id, timeout) +def _reported_config(cluster): + """ + Connects `cluster` and returns the configuration its control connection + reported, read back out of the clients table and validated against the + shared schema. + + Read back rather than built locally, because what the server received is the + only thing these tests can say more about than the unit tests can. The + report is validated on the way through: every configuration a test here + connects with is one this driver may really send, so each of them is a + conformance case too. + + Only the control connection reports, so the wait is for a listed connection + carrying a report rather than for a number of listed connections. + """ + session = cluster.connect(wait_for_all_pools=True) + session_id = str(cluster.session_id) + + options = _wait_for_reported_config(session, session_id) + _assert_listed(options, 1, session_id) + + reports = [o[DRIVER_CONFIG_OPTION] for o in options if DRIVER_CONFIG_OPTION in o] + assert reports, "the control connection reported no configuration" + + return validate_report(reports[0]) + + @xfail_scylla_version_lt(reason='scylladb/scylla-enterprise#5467 - system.client_options is not yet supported', scylla_version="2026.1.0") class DriverConfigReportingTest(unittest.TestCase): @@ -194,7 +254,93 @@ def test_only_the_control_connection_reports_the_driver_config(self): ("expected exactly one connection to report %s, got %d. If the control " "connection reconnected during this test, the closed one may still be " "listed with a report of its own." % (DRIVER_CONFIG_OPTION, len(reports))) - assert json.loads(reports[0]) == {'version': DRIVER_CONFIG_SCHEMA_VERSION} + + # Validated rather than compared: what the report has to be is + # whatever the shared schema allows, and pinning the document itself + # here would duplicate the unit tests and break on every group added + # to it. + report = validate_report(reports[0]) + assert report['version'] == DRIVER_CONFIG_SCHEMA_VERSION + finally: + cluster.shutdown() + + def test_the_reported_configuration_survives_the_round_trip(self): + """ + The report an operator reads out of the clients table describes the + client that wrote it. + + Everything here is set away from its default, so a report built from the + wrong source, or from defaults, fails rather than happening to match. + + TLS is not among them: the node these tests run against does not serve + it, so there is no configuration that would both connect and report a + tls group. What that group contains is settled in the unit tests. + """ + cluster = TestCluster( + connect_timeout=11, + control_connection_timeout=7, + max_schema_agreement_wait=13, + reconnection_policy=ConstantReconnectionPolicy(2.5, max_attempts=4), + sockopts=[(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)], + execution_profiles={EXEC_PROFILE_DEFAULT: ExecutionProfile( + consistency_level=ConsistencyLevel.QUORUM, + serial_consistency_level=ConsistencyLevel.LOCAL_SERIAL, + request_timeout=6, + retry_policy=FallthroughRetryPolicy(), + speculative_execution_policy=ConstantSpeculativeExecutionPolicy(0.75, 2), + )}) + try: + report = _reported_config(cluster) + + assert report['connection']['connect']['timeout-ms'] == 11000 + assert report['connection']['socket']['tcp-no-delay'] is True + assert report['connection']['reconnection']['policy'] == { + 'type': 'constant', 'delay-ms': 2500, 'max-attempts': 4} + # No TLS on this node, so the group describing it is absent. + assert 'tls' not in report['connection'] + + control_plane = report['control-plane'] + assert control_plane['queries']['system']['timeout']['client-side-ms'] == 7000 + assert control_plane['schema']['agreement']['timeout-ms'] == 13000 + + query = report['query'] + assert query['defaults']['consistency'] == 'QUORUM' + assert query['defaults']['serial-consistency'] == 'LOCAL_SERIAL' + assert query['defaults']['request']['timeout-ms'] == 6000 + assert query['retry']['policy'] == {'type': 'fallthrough'} + assert query['speculative-execution']['policy'] == { + 'type': 'constant', 'max-executions': 2, 'delay-ms': 750} + finally: + cluster.shutdown() + + def test_the_server_side_timeout_is_reported_against_scylla(self): + """ + USING TIMEOUT is a ScyllaDB extension, so this key is reported only on a + connection to a ScyllaDB node -- which is what these tests run against. + The unit tests can only assert it for a flag they pass in themselves; + this is the one place the detection itself is exercised. + """ + cluster = TestCluster(metadata_request_timeout=9) + try: + report = _reported_config(cluster) + + assert report['control-plane']['queries']['system']['timeout']['server-side-ms'] == 9000 + finally: + cluster.shutdown() + + def test_the_local_datacenter_is_reported_as_inferred(self): + """ + The driver is not told a datacenter here, so it infers one, and the + report has to say which of the two happened: an operator reading `dc` + would take it for a deliberate choice the application made. + """ + cluster = TestCluster() + try: + node_preference = _reported_config(cluster)['query']['load-balancing'].get( + 'node-preference') + + assert node_preference is not None + assert node_preference['type'] == 'dc-auto' finally: cluster.shutdown() From a08a0b90747ebd575f9717d249e3c20bdd4d5e6c Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Tue, 25 Aug 2026 14:12:53 +0200 Subject: [PATCH 166/200] DRIVER-379: Document what the configuration report describes The guide said the report carried a version and that more keys would follow. Now that they have, it describes the three groups, shows what a default Cluster reports, and links the schema where it is maintained -- an operator reading a report may well not be reading this driver's. Five things get called out, because each is a way to misread a report rather than a detail of it: that only the default execution profile is described, that a custom policy is named and never serialized, that an absent key means "does not apply" rather than "off", that the datacenter says whether it was configured or inferred, and that the query defaults are a snapshot taken before any Session exists. The example is the real output of a default Cluster, verified against it rather than written by hand. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.rst | 11 ++++ docs/scylla-specific.rst | 135 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index c209ccaa67..26725cfedc 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,6 +14,17 @@ Features ``Cluster(driver_config_reporting_enabled=False)``; ``SESSION_ID`` is unaffected by that setting. Reporting is best effort and never prevents a connection from being established. +* ``DRIVER_CONFIG`` now describes the configuration itself rather than only the schema + version it follows (DRIVER-379). The report covers connection settings (timeouts, + request capacity, shard awareness, socket options, reconnection policy, TLS hostname + verification), the driver's own control-plane query timeouts, and the query defaults + and policies a statement gets when it overrides none of them. It follows the JSON + schema shared with the other ScyllaDB drivers, so the same document describes a + client whichever driver wrote it. Custom policies are reported by type name only and + never by their attributes, so a policy holding a credential does not leak it into the + clients table. +* ``Cluster.sockopts`` is now materialized at construction, so a one-shot iterable is + applied to every connection the cluster opens rather than only to the first one. * Negotiate and implement the ``SCYLLA_USE_METADATA_ID`` protocol extension: prepared statements skip re-sending result metadata on EXECUTE, and the driver automatically refreshes cached metadata when the server detects a schema change (DRIVER-153) diff --git a/docs/scylla-specific.rst b/docs/scylla-specific.rst index 92df047530..9ca73b90ac 100644 --- a/docs/scylla-specific.rst +++ b/docs/scylla-specific.rst @@ -328,6 +328,141 @@ Two of the options are about the driver rather than the protocol: driver learns to describe more of its configuration, and adding one does not bump the version. + The schema is shared with the other ScyllaDB drivers, so the same document + describes a Go or C# client in the same terms. It is maintained + `upstream + `_. + +What the report describes +~~~~~~~~~~~~~~~~~~~~~~~~~ + +Three groups, each named for the part of the driver it covers: + +``connection`` + What the driver does with a single connection: the connect timeout, how many + requests one connection carries, whether pools use ScyllaDB's shard-aware + port, the socket options from ``sockopts``, the reconnection policy, and -- + when TLS is configured -- whether the server hostname is verified. + +``control-plane`` + The timeouts on the driver's own queries, the ones it runs to discover the + cluster rather than on behalf of the application: ``control_connection_timeout`` + as a client-side limit, ``metadata_request_timeout`` as the server-side one + the driver applies with ``USING TIMEOUT``, and ``max_schema_agreement_wait``. + +``query`` + What a statement gets when it overrides nothing: the default consistency, + serial consistency, page size, request timeout and timestamp behaviour, along + with the retry, load balancing and speculative execution policies. + +A report from a default ``Cluster()`` looks like this, reformatted -- what goes +on the wire has no whitespace: + +.. code:: json + + { + "version": 1, + "connection": { + "connect": {"timeout-ms": 5000}, + "requests": {"in-flight": {"max": 32767}, "orphaned": {"max": 24575}}, + "pool": {"shard-aware": {"enabled": true}}, + "socket": {"tcp-no-delay": false, "keep-alive": false, "reuse-address": false}, + "reconnection": {"policy": {"type": "exponential", "base-ms": 1000, "max-ms": 600000}} + }, + "control-plane": { + "queries": {"system": {"timeout": {"client-side-ms": 2000, "server-side-ms": 2000}}}, + "schema": {"agreement": {"timeout-ms": 10000}} + }, + "query": { + "defaults": { + "consistency": "LOCAL_ONE", + "idempotence": false, + "request": {"timeout-ms": 10000}, + "page": {"size": 5000}, + "client-timestamps": true + }, + "retry": {"policy": {"type": "standard-error-aware"}}, + "load-balancing": { + "policy": { + "type": "token-aware", + "load-distribution": "shuffle", + "fallback-to-non-preferred-nodes": false + }, + "node-preference": {"type": "dc-auto"} + } + } + } + +Five things are worth knowing when reading one: + +**Only the default execution profile is described.** The schema has a single +``query`` group, so what it reports is the profile a statement gets when it +names none -- ``EXEC_PROFILE_DEFAULT``. Policies and defaults set on other +profiles do not appear. A ``load_balancing_policy`` or ``default_retry_policy`` +passed to the ``Cluster`` constructor is folded into that same profile, so both +ways of configuring the driver read identically here. + +**A custom policy is reported by name only.** The driver never serializes a +policy object's attributes. A policy is an ordinary Python object and whatever +it happens to hold -- an auth provider, a credential, a host list -- would +otherwise land in the clients table for anyone who can read it. A policy the +driver does not recognise is reported as +``{"type": "custom", "name": "YourPolicy"}`` and nothing more, named after the +policy you configured rather than whatever sits inside it. + +The load balancing group asks a little more than that. Its built-in +``token-aware`` shape carries flags describing where a request may go, so it is +claimed only when *every* policy in the chain is one the driver can account for +-- a token-aware policy over ``DCAwareRoundRobinPolicy``, +``RackAwareRoundRobinPolicy`` or ``RoundRobinPolicy``. A chain reaching anything +else is reported as custom even with a token-aware policy wrapping it, because +the flags would otherwise assert plain token-aware routing and say nothing of +what the inner policy does. ``WhiteListRoundRobinPolicy`` and +``HostFilterPolicy`` both fall here: each confines routing to a subset of the +cluster that the flags have nowhere to record. + +``node-preference`` is reported either way -- it describes where requests go, +not which policy sends them, so a ``DCAwareRoundRobinPolicy`` or +``RackAwareRoundRobinPolicy`` reports its datacenter whether it is used on its +own, wrapped, or sitting inside a chain reported as custom. + +**Some keys are absent rather than false.** The schema uses absence to mean +"this does not apply" or "this is not knowable", so a missing key is not a +disabled setting. ``tls`` is absent when TLS is not configured; +``server-side-ms`` when the connection is not to a ScyllaDB node, since +``USING TIMEOUT`` is a ScyllaDB extension; ``speculative-execution`` when no +speculative execution is configured; and ``client-timestamps`` when a custom +``timestamp_generator`` makes it impossible to say whether the client will +assign a timestamp. + +**The datacenter says whether it was chosen or guessed.** A ``node-preference`` +of type ``dc`` carries a datacenter the application configured; ``dc-auto`` +means the driver inferred one from the first host it saw, and its ``local-dc`` +is absent until it has. The first report a cluster sends is usually the latter, +since the control connection reports before any host has come up. + +**``query.defaults`` is a cluster-level snapshot.** It is built when the control +connection is established, before any :class:`~.Session` exists. Under execution +profiles the default profile is what it describes. In legacy configuration mode +the consistency, the serial consistency and the request timeout come from the +``Session`` instead -- ``Session.default_consistency_level``, +``default_serial_consistency_level`` and ``default_timeout``, which is where a +legacy request reads them -- and ``default_fetch_size`` and +``use_client_timestamp`` come from there in both modes. + +All five live on the ``Session``, and no session exists yet when the report is +built, so what is reported is the default every session created from the cluster +will start with. Setting one of them on a session after ``connect()`` does not +change what was reported, and is not picked up by a report a later control +connection builds either. + +Values the driver has no way to express under this schema version -- +``idle_heartbeat_interval``, the protocol version, compression, and non-default +execution profiles -- are left out rather than approximated. + +Reading and controlling the options +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + .. code:: python from cassandra.cluster import Cluster From b666e5c6e172992976a53344af2b88d78648f6ed Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Thu, 10 Sep 2026 16:57:49 -0400 Subject: [PATCH 167/200] cluster: preserve Unix control endpoint for local host Keep a newly discovered local host on the Unix socket used by the control connection while retaining advertised network metadata and factory-based duplicate detection. Resolve control hosts by stable identity across topology refreshes, schema checks, error attribution, and DOWN/REMOVE callbacks. Preserve direct reconnect fallback for Unix-backed and alternate routes when DOWN handling queues no callback. Keep shard-aware pools on the Unix route without advertised TCP ports or source-port shard targeting, and make mixed Unix/network Host ordering deterministic. --- cassandra/cluster.py | 125 +++++++++-- cassandra/connection.py | 2 + cassandra/metadata.py | 5 +- cassandra/pool.py | 13 +- tests/unit/test_control_connection.py | 304 +++++++++++++++++++++++++- tests/unit/test_metadata.py | 27 ++- tests/unit/test_shard_aware.py | 37 +++- 7 files changed, 488 insertions(+), 25 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 57fcf46331..d858f5835e 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -51,7 +51,8 @@ from cassandra.connection import (ClientRoutesEndPointFactory, ConnectionException, ConnectionShutdown, ConnectionHeartbeat, ProtocolVersionUnsupported, EndPoint, DefaultEndPoint, DefaultEndPointFactory, - SniEndPointFactory, ConnectionBusy, locally_supported_compressions) + SniEndPointFactory, UnixSocketEndPoint, + ConnectionBusy, locally_supported_compressions) from cassandra.cqltypes import UserType import cassandra.cqltypes as types from cassandra.encoder import Encoder @@ -2225,8 +2226,7 @@ def get_control_connection_host(self): Returns the control connection host metadata. """ connection = self.control_connection._connection - endpoint = connection.endpoint if connection else None - return self.metadata.get_host(endpoint) if endpoint else None + return self.control_connection._get_host_for_connection(connection) def refresh_schema_metadata(self, max_schema_agreement_wait=None): """ @@ -4131,6 +4131,7 @@ def _refresh_node_list_and_token_map(self, connection, preloaded_results=None, found_host_ids = set() found_endpoints = set() + local_row = None if local_result.parsed_rows: local_rows = dict_factory(local_result.column_names, local_result.parsed_rows) local_row = local_rows[0] @@ -4150,11 +4151,13 @@ def _refresh_node_list_and_token_map(self, connection, preloaded_results=None, if not self._is_valid_peer(row): continue - endpoint = self._cluster.endpoint_factory.create(row) + factory_endpoint = self._cluster.endpoint_factory.create(row) host_id = row.get("host_id") - if endpoint in found_endpoints: - log.warning("Found multiple hosts with the same endpoint(%s). Excluding peer %s - %s", endpoint, row.get("peer"), host_id) + # Use the factory endpoint for duplicate detection even when a Unix + # socket is retained as the route to the local host. + if factory_endpoint in found_endpoints: + log.warning("Found multiple hosts with the same endpoint(%s). Excluding peer %s - %s", factory_endpoint, row.get("peer"), host_id) continue if host_id in found_host_ids: @@ -4162,13 +4165,28 @@ def _refresh_node_list_and_token_map(self, connection, preloaded_results=None, continue found_host_ids.add(host_id) - found_endpoints.add(endpoint) + found_endpoints.add(factory_endpoint) + existing_host = self._cluster.metadata.get_host_by_host_id(host_id) + + # Host hashes depend on their endpoint, so never replace the route + # of an existing Host with or from a Unix socket. A newly discovered + # local Host keeps the socket which actually reached the node. + if (existing_host is not None and + isinstance(existing_host.endpoint, UnixSocketEndPoint)): + endpoint = existing_host.endpoint + elif (existing_host is None and row is local_row and + isinstance(connection.original_endpoint, + UnixSocketEndPoint)): + endpoint = connection.original_endpoint + else: + endpoint = factory_endpoint + host = self._cluster.metadata.get_host(endpoint) datacenter = row.get("data_center") rack = row.get("rack") if host is None: - host = self._cluster.metadata.get_host_by_host_id(host_id) + host = existing_host if host and host.endpoint != endpoint: log.debug("[control connection] Updating host ip from %s to %s for (%s)", host.endpoint, endpoint, host_id) reconnector = host.get_and_set_reconnection_handler(None) @@ -4198,6 +4216,9 @@ def _refresh_node_list_and_token_map(self, connection, preloaded_results=None, host.dse_workload = row.get("workload") host.dse_workloads = row.get("workloads") + if row is local_row: + connection._control_connection_host_id = host_id + tokens = row.get("tokens", None) if partitioner and tokens and self._token_meta_enabled: token_map[host] = tokens @@ -4465,8 +4486,15 @@ def _get_schema_mismatches(self, peers_result, local_result, local_address): continue endpoint = self._cluster.endpoint_factory.create(row) peer = self._cluster.metadata.get_host(endpoint) + if peer is None: + peer_by_host_id = self._cluster.metadata.get_host_by_host_id( + row.get('host_id')) + if (peer_by_host_id is not None and + isinstance(peer_by_host_id.endpoint, + UnixSocketEndPoint)): + peer = peer_by_host_id if peer and peer.is_up is not False: - versions[schema_ver].add(endpoint) + versions[schema_ver].add(peer.endpoint) if len(versions) == 1: log.debug("[control connection] Schemas match") @@ -4474,6 +4502,34 @@ def _get_schema_mismatches(self, peers_result, local_result, local_address): return dict((version, list(nodes)) for version, nodes in versions.items()) + def _get_host_for_connection(self, connection): + if connection is None: + return None + + host_id = getattr(connection, '_control_connection_host_id', None) + if host_id is not None: + host = self._cluster.metadata.get_host_by_host_id(host_id) + if host is not None: + return host + + original_endpoint = getattr(connection, 'original_endpoint', None) + if original_endpoint is not None: + host = self._cluster.metadata.get_host(original_endpoint) + if host is not None: + return host + + return self._cluster.metadata.get_host(connection.endpoint) + + def _connection_matches_host(self, connection, host): + if connection is None: + return False + + host_id = getattr(connection, '_control_connection_host_id', None) + if host_id is not None and host_id == host.host_id: + return True + + return self._get_host_for_connection(connection) is host + def _get_peers_query(self, peers_query_type, connection=None): """ Determine the peers query to use. @@ -4504,9 +4560,10 @@ def _get_peers_query(self, peers_query_type, connection=None): query_template = (self._SELECT_SCHEMA_PEERS_TEMPLATE if peers_query_type == self.PeersQueryType.PEERS_SCHEMA else self._SELECT_PEERS_NO_TOKENS_TEMPLATE) - original_endpoint_host = self._cluster.metadata.get_host(connection.original_endpoint) - host_release_version = None if original_endpoint_host is None else original_endpoint_host.release_version - host_dse_version = None if original_endpoint_host is None else original_endpoint_host.dse_version + connection_host = self._get_host_for_connection( + connection) + host_release_version = None if connection_host is None else connection_host.release_version + host_dse_version = None if connection_host is None else connection_host.dse_version uses_native_address_query = ( host_dse_version and Version(host_dse_version) >= self._MINIMUM_NATIVE_ADDRESS_DSE_VERSION) @@ -4527,13 +4584,45 @@ def _signal_error(self): # try just signaling the cluster, as this will trigger a reconnect # as part of marking the host down if self._connection and self._connection.is_defunct: - host = self._cluster.metadata.get_host(self._connection.endpoint) + connection = self._connection + host = self._get_host_for_connection(connection) # host may be None if it's already been removed, but that indicates # that errors have already been reported, so we're fine if host: - self._cluster.signal_connection_failure( - host, self._connection.last_error, is_host_addition=False) - return + original_endpoint = getattr( + connection, 'original_endpoint', None) + unix_backed = ( + isinstance(host.endpoint, UnixSocketEndPoint) or + isinstance(connection.endpoint, UnixSocketEndPoint) or + isinstance(original_endpoint, UnixSocketEndPoint)) + route_mismatch = connection.endpoint != host.endpoint + # Keep ordinary endpoint-equal TCP connections on the + # legacy signal-only path. General suppressed-DOWN recovery + # and its reconnection cadence are outside this change. + if not unix_backed and not route_mismatch: + self._cluster.signal_connection_failure( + host, connection.last_error, + is_host_addition=False) + return + + # A newly resolvable Unix Host or alternate connection + # route still needs the direct reconnect fallback when + # host-state handling suppresses its DOWN notification. A + # fresh DOWN transition guarantees that on_down() will + # enqueue the reconnect instead. + with host.lock: + host_was_up = host.is_up is True + host_was_reconnecting = ( + host.is_currently_reconnecting()) + self._cluster.signal_connection_failure( + host, connection.last_error, + is_host_addition=False) + down_notification_queued = ( + host_was_up and not host_was_reconnecting and + host.is_up is False) + + if down_notification_queued: + return # if the connection is not defunct or the host already left, reconnect # manually @@ -4545,7 +4634,7 @@ def on_up(self, host): def on_down(self, host): conn = self._connection - if conn and conn.endpoint == host.endpoint and \ + if self._connection_matches_host(conn, host) and \ self._reconnection_handler is None: log.debug("[control connection] Control connection host (%s) is " "considered down, starting reconnection", host) @@ -4558,7 +4647,7 @@ def on_add(self, host, refresh_nodes=True): def on_remove(self, host): c = self._connection - if c and c.endpoint == host.endpoint: + if self._connection_matches_host(c, host): log.debug("[control connection] Control connection host (%s) is being removed. Reconnecting", host) # refresh will be done on reconnect self.reconnect() diff --git a/cassandra/connection.py b/cassandra/connection.py index b4ea59b23c..d0a75818b2 100644 --- a/cassandra/connection.py +++ b/cassandra/connection.py @@ -899,6 +899,8 @@ def orphaned_threshold_for(max_in_flight): is_unsupported_proto_version = False is_control_connection = False + # Stable identity learned from system.local for control connections. + _control_connection_host_id = None signaled_error = False # used for flagging at the pool level allow_beta_protocol_version = False diff --git a/cassandra/metadata.py b/cassandra/metadata.py index 0cb17e1337..25d1ceb7d5 100644 --- a/cassandra/metadata.py +++ b/cassandra/metadata.py @@ -140,7 +140,10 @@ def export_schema_as_string(self): def refresh(self, connection, timeout, target_type=None, change_type=None, fetch_size=None, metadata_request_timeout=None, **kwargs): - host = self.get_host(connection.original_endpoint) + host_id = getattr(connection, '_control_connection_host_id', None) + host = self.get_host_by_host_id(host_id) if host_id is not None else None + if host is None: + host = self.get_host(connection.original_endpoint) server_version = host.release_version if host else None dse_version = host.dse_version if host else None parser = get_schema_parser(connection, server_version, dse_version, timeout, metadata_request_timeout, fetch_size) diff --git a/cassandra/pool.py b/cassandra/pool.py index 1d90e3233f..2cd376d293 100644 --- a/cassandra/pool.py +++ b/cassandra/pool.py @@ -29,7 +29,8 @@ from cassandra.util import WeakSet # NOQA from cassandra import AuthenticationFailed -from cassandra.connection import ConnectionException, EndPoint, DefaultEndPoint +from cassandra.connection import (ConnectionException, EndPoint, + DefaultEndPoint, UnixSocketEndPoint) from cassandra.policies import HostDistance log = logging.getLogger(__name__) @@ -241,6 +242,12 @@ def __hash__(self): return hash(self.endpoint) def __lt__(self, other): + self_is_unix = isinstance(self.endpoint, UnixSocketEndPoint) + other_is_unix = isinstance(other.endpoint, UnixSocketEndPoint) + if self_is_unix != other_is_unix: + # Endpoint comparators assume same-kind operands, so partition + # Unix and network Hosts before delegating their ordering. + return self_is_unix return self.endpoint < other.endpoint def __str__(self): @@ -694,7 +701,11 @@ def _get_shard_aware_endpoint(self): shard_aware_port_ssl; if it is absent, return None so the pool opens a regular SSL connection instead of falling back to the plaintext port. Explicit ssl_options={}, like ssl_context, marks the cluster SSL-enabled. + Unix sockets bypass advertised TCP ports and source-port shard targeting. """ + if isinstance(self.host.endpoint, UnixSocketEndPoint): + return None + if (self.advanced_shardaware_block_until and self.advanced_shardaware_block_until > time.time()) or \ self._session.cluster.shard_aware_options.disable_shardaware_port: return None diff --git a/tests/unit/test_control_connection.py b/tests/unit/test_control_connection.py index fd62323f33..dec61dacdc 100644 --- a/tests/unit/test_control_connection.py +++ b/tests/unit/test_control_connection.py @@ -19,9 +19,12 @@ from cassandra import OperationTimedOut, SchemaTargetType, SchemaChangeType from cassandra.protocol import ResultMessage, RESULT_KIND_ROWS -from cassandra.cluster import ControlConnection, _Scheduler, ProfileManager, EXEC_PROFILE_DEFAULT, ExecutionProfile +from cassandra.cluster import (Cluster, ControlConnection, _Scheduler, + ProfileManager, EXEC_PROFILE_DEFAULT, + ExecutionProfile) from cassandra.pool import Host -from cassandra.connection import EndPoint, DefaultEndPoint, DefaultEndPointFactory +from cassandra.connection import (ConnectionException, EndPoint, DefaultEndPoint, + DefaultEndPointFactory, UnixSocketEndPoint) from cassandra.policies import (SimpleConvictionPolicy, RoundRobinPolicy, ConstantReconnectionPolicy, IdentityTranslator) @@ -80,8 +83,8 @@ def add_or_return_host(self, host): def update_host(self, host, old_endpoint): host, created = self.add_or_return_host(host) - self._host_id_by_endpoint[host.endpoint] = host.host_id self._host_id_by_endpoint.pop(old_endpoint, False) + self._host_id_by_endpoint[host.endpoint] = host.host_id def all_hosts_items(self): return list(self.hosts.items()) @@ -205,6 +208,27 @@ def setUp(self): self.control_connection = ControlConnection(self.cluster, 1, 0, 0, 0) self.control_connection._connection = self.connection self.control_connection._time = self.time + self.cluster.control_connection = self.control_connection + + def _forget_local_host(self): + endpoint = DefaultEndPoint('192.168.1.0') + self.cluster.metadata._host_id_by_endpoint.pop(endpoint) + self.cluster.metadata.hosts.pop('uuid1') + + def _discover_local_host_over_unix(self): + maintenance_endpoint = UnixSocketEndPoint('/tmp/maintenance.sock') + self._forget_local_host() + self.connection.endpoint = maintenance_endpoint + self.connection.original_endpoint = maintenance_endpoint + self.control_connection.refresh_node_list_and_token_map() + local_host = self.cluster.metadata.get_host_by_host_id('uuid1') + local_host.set_up() + return maintenance_endpoint, local_host + + def _refresh_control_connection_over_network(self): + self.connection.endpoint = DefaultEndPoint('192.168.1.0') + self.connection.original_endpoint = self.connection.endpoint + self.control_connection.refresh_node_list_and_token_map() def test_wait_for_schema_agreement(self): """ @@ -330,6 +354,280 @@ def test_refresh_nodes_and_tokens(self): assert self.connection.wait_for_responses.call_count == 1 + def test_refresh_uses_control_endpoint_for_local_unix_host(self): + maintenance_endpoint = UnixSocketEndPoint('/tmp/maintenance.sock') + self._forget_local_host() + self.connection.endpoint = maintenance_endpoint + self.connection.original_endpoint = maintenance_endpoint + + self.control_connection.refresh_node_list_and_token_map() + + local_host = self.cluster.metadata.get_host_by_host_id('uuid1') + assert local_host.endpoint == maintenance_endpoint + assert local_host.broadcast_rpc_address == '192.168.1.0' + peer_host = self.cluster.metadata.get_host_by_host_id('uuid2') + assert peer_host.endpoint == DefaultEndPoint('192.168.1.1') + assert sorted([local_host, peer_host]) == \ + sorted([peer_host, local_host]) + + def test_refresh_checks_unix_local_advertised_endpoint_for_duplicates(self): + self._forget_local_host() + self.connection.endpoint = UnixSocketEndPoint('/tmp/maintenance.sock') + self.connection.original_endpoint = \ + UnixSocketEndPoint('/tmp/maintenance.sock') + self.connection.peer_results[1].append([ + '192.168.1.0', '10.0.0.4', 'a', 'dc1', 'rack1', + ['4', '104', '204'], 'uuid4']) + + self.control_connection.refresh_node_list_and_token_map() + + assert self.cluster.metadata.get_host_by_host_id('uuid4') is None + + def test_refresh_preserves_known_unix_endpoint_when_host_becomes_peer(self): + maintenance_endpoint = UnixSocketEndPoint('/tmp/maintenance.sock') + self._forget_local_host() + self.connection.endpoint = maintenance_endpoint + self.connection.original_endpoint = maintenance_endpoint + self.control_connection.refresh_node_list_and_token_map() + + local_results = ( + self.connection.local_results[0], + [['192.168.1.1', 'a', 'foocluster', 'dc1', 'rack1', + 'Murmur3Partitioner', '2.2.0', ['1', '101', '201'], + 'uuid2']]) + peer_results = ( + self.connection.peer_results[0], + [['192.168.1.0', '10.0.0.1', 'a', 'dc1', 'rack1', + ['0', '100', '200'], 'uuid1'], + ['192.168.1.2', '10.0.0.2', 'a', 'dc1', 'rack1', + ['2', '102', '202'], 'uuid3']]) + self.connection.endpoint = DefaultEndPoint('192.168.1.1') + self.connection.original_endpoint = self.connection.endpoint + + self.control_connection._refresh_node_list_and_token_map( + self.connection, + preloaded_results=_node_meta_results(local_results, peer_results)) + + local_host = self.cluster.metadata.get_host_by_host_id('uuid1') + assert local_host.endpoint == maintenance_endpoint + + peer_results[1][0][2] = 'b' + peers_response, local_response = _node_meta_results( + local_results, peer_results) + mismatches = self.control_connection._get_schema_mismatches( + peers_response, local_response, self.connection.endpoint) + assert maintenance_endpoint in mismatches['b'] + + def test_refresh_uses_factory_for_local_network_host(self): + self.connection.original_endpoint = DefaultEndPoint('proxy', 9999) + + self.control_connection.refresh_node_list_and_token_map() + + local_host = self.cluster.metadata.get_host_by_host_id('uuid1') + assert local_host.endpoint == DefaultEndPoint('192.168.1.0') + + def test_schema_query_uses_shard_aware_connection_original_endpoint(self): + host = self.cluster.metadata.get_host_by_host_id('uuid1') + self.connection.endpoint = DefaultEndPoint('192.168.1.0', 19042) + self.connection.original_endpoint = host.endpoint + self.control_connection._uses_peers_v2 = False + + query = self.control_connection._get_peers_query( + self.control_connection.PeersQueryType.PEERS_SCHEMA, + self.connection) + + assert query == self.control_connection._SELECT_SCHEMA_PEERS_TEMPLATE \ + .format(nt_col_name='rpc_address') + + def test_refresh_network_local_preserves_known_unix_endpoint(self): + maintenance_endpoint, local_host = \ + self._discover_local_host_over_unix() + host_index = {local_host: object()} + + self._refresh_control_connection_over_network() + + assert self.cluster.metadata.get_host_by_host_id('uuid1') is local_host + assert local_host.endpoint == maintenance_endpoint + assert host_index[local_host] is not None + assert Cluster.get_control_connection_host(self.cluster) is local_host + + connection_error = ConnectionException('control connection failed') + self.connection.is_defunct = True + self.connection.last_error = connection_error + # Model a conviction whose DOWN transition is discounted because a + # usable session pool remains: no control on_down callback is queued. + self.cluster.signal_connection_failure = Mock(return_value=True) + self.cluster.executor.reset_mock() + + self.control_connection._signal_error() + + self.cluster.signal_connection_failure.assert_called_once_with( + local_host, connection_error, is_host_addition=False) + self.cluster.executor.submit.assert_called_once_with( + self.control_connection._reconnect) + + def test_unix_signal_error_reconnects_if_down_notification_suppressed(self): + _, local_host = self._discover_local_host_over_unix() + connection_error = ConnectionException('control connection failed') + self.connection.is_defunct = True + self.connection.last_error = connection_error + self.cluster.signal_connection_failure = Mock(return_value=True) + self.cluster.executor.reset_mock() + + self.control_connection._signal_error() + + self.cluster.signal_connection_failure.assert_called_once_with( + local_host, connection_error, is_host_addition=False) + self.cluster.executor.submit.assert_called_once_with( + self.control_connection._reconnect) + + def test_tcp_route_mismatch_reconnects_if_down_notification_suppressed(self): + self.control_connection.refresh_node_list_and_token_map() + local_host = self.cluster.metadata.get_host_by_host_id('uuid1') + self.connection.endpoint = DefaultEndPoint('192.168.1.0', 19042) + self.connection.original_endpoint = local_host.endpoint + connection_error = ConnectionException('control connection failed') + self.connection.is_defunct = True + self.connection.last_error = connection_error + self.cluster.signal_connection_failure = Mock(return_value=True) + self.cluster.executor.reset_mock() + + self.control_connection._signal_error() + + self.cluster.signal_connection_failure.assert_called_once_with( + local_host, connection_error, is_host_addition=False) + self.cluster.executor.submit.assert_called_once_with( + self.control_connection._reconnect) + + def test_route_mismatch_signal_error_waits_for_queued_down_reconnect(self): + _, local_host = self._discover_local_host_over_unix() + self._refresh_control_connection_over_network() + connection_error = ConnectionException('control connection failed') + self.connection.is_defunct = True + self.connection.last_error = connection_error + down_notifications = [] + + def transition_host_down(host, *_args, **_kwargs): + host.set_down() + down_notifications.append(host) + return True + + self.cluster.signal_connection_failure = Mock( + side_effect=transition_host_down) + self.cluster.executor.reset_mock() + + self.control_connection._signal_error() + + self.cluster.signal_connection_failure.assert_called_once_with( + local_host, connection_error, is_host_addition=False) + self.cluster.executor.submit.assert_not_called() + + self.control_connection.on_down(down_notifications.pop()) + + self.cluster.executor.submit.assert_called_once_with( + self.control_connection._reconnect) + + def test_route_mismatch_signal_error_reconnects_if_host_already_down(self): + _, local_host = self._discover_local_host_over_unix() + self._refresh_control_connection_over_network() + local_host.set_down() + connection_error = ConnectionException('control connection failed') + self.connection.is_defunct = True + self.connection.last_error = connection_error + self.cluster.signal_connection_failure = Mock(return_value=True) + self.cluster.executor.reset_mock() + + self.control_connection._signal_error() + + self.cluster.signal_connection_failure.assert_called_once_with( + local_host, connection_error, is_host_addition=False) + self.cluster.executor.submit.assert_called_once_with( + self.control_connection._reconnect) + + def test_route_mismatch_signal_error_reconnects_if_host_reconnecting(self): + _, local_host = self._discover_local_host_over_unix() + self._refresh_control_connection_over_network() + local_host.get_and_set_reconnection_handler(Mock()) + connection_error = ConnectionException('control connection failed') + self.connection.is_defunct = True + self.connection.last_error = connection_error + + def transition_without_notification(host, *_args, **_kwargs): + host.set_down() + return True + + self.cluster.signal_connection_failure = Mock( + side_effect=transition_without_notification) + self.cluster.executor.reset_mock() + + self.control_connection._signal_error() + + self.cluster.signal_connection_failure.assert_called_once_with( + local_host, connection_error, is_host_addition=False) + self.cluster.executor.submit.assert_called_once_with( + self.control_connection._reconnect) + + def test_remove_matches_control_connection_by_host_id(self): + maintenance_endpoint = UnixSocketEndPoint('/tmp/maintenance.sock') + self._forget_local_host() + self.connection.endpoint = maintenance_endpoint + self.connection.original_endpoint = maintenance_endpoint + self.control_connection.refresh_node_list_and_token_map() + local_host = self.cluster.metadata.get_host_by_host_id('uuid1') + + self.connection.endpoint = DefaultEndPoint('192.168.1.0') + self.cluster.metadata.hosts.pop('uuid1') + self.cluster.metadata._host_id_by_endpoint.pop(maintenance_endpoint) + self.cluster.executor.reset_mock() + + self.control_connection.on_remove(local_host) + + self.cluster.executor.submit.assert_called_once_with( + self.control_connection._reconnect) + + def test_down_matches_replacement_at_stale_control_endpoint(self): + self.control_connection.refresh_node_list_and_token_map() + old_host = self.cluster.metadata.get_host_by_host_id('uuid1') + endpoint = old_host.endpoint + self.cluster.metadata.hosts.pop('uuid1') + + replacement_host = Host( + endpoint, SimpleConvictionPolicy, host_id='replacement-id') + replacement_host.set_up() + self.cluster.metadata.hosts['replacement-id'] = replacement_host + self.cluster.metadata._host_id_by_endpoint[endpoint] = \ + 'replacement-id' + + connection_error = ConnectionException('old control failed') + self.connection.is_defunct = True + self.connection.last_error = connection_error + self.cluster.signal_connection_failure = Mock(return_value=True) + self.cluster.executor.reset_mock() + + self.control_connection._signal_error() + + self.cluster.signal_connection_failure.assert_called_once_with( + replacement_host, connection_error, is_host_addition=False) + self.cluster.executor.submit.assert_not_called() + + self.control_connection.on_down(replacement_host) + + self.cluster.executor.submit.assert_called_once_with( + self.control_connection._reconnect) + + def test_refresh_unix_local_preserves_known_network_endpoint(self): + maintenance_endpoint = UnixSocketEndPoint('/tmp/maintenance.sock') + local_host = self.cluster.metadata.get_host_by_host_id('uuid1') + host_index = {local_host: object()} + self.connection.endpoint = maintenance_endpoint + self.connection.original_endpoint = maintenance_endpoint + + self.control_connection.refresh_node_list_and_token_map() + + assert self.cluster.metadata.get_host_by_host_id('uuid1') is local_host + assert local_host.endpoint == DefaultEndPoint('192.168.1.0') + assert host_index[local_host] is not None + def test_refresh_nodes_and_tokens_with_invalid_peers(self): def refresh_and_validate_added_hosts(): self.connection.wait_for_responses = Mock(return_value=_node_meta_results( diff --git a/tests/unit/test_metadata.py b/tests/unit/test_metadata.py index 2a1fced6cf..ced388414f 100644 --- a/tests/unit/test_metadata.py +++ b/tests/unit/test_metadata.py @@ -15,11 +15,12 @@ from binascii import unhexlify import logging -from unittest.mock import Mock +from unittest.mock import Mock, patch import os import uuid import cassandra +from cassandra.connection import DefaultEndPoint, UnixSocketEndPoint from cassandra.cqltypes import strip_frozen from cassandra.marshal import uint16_unpack, uint16_pack from cassandra.metadata import (Murmur3Token, MD5Token, @@ -829,6 +830,30 @@ def test_build_index_as_cql(self): class SchemaParserLookupTests(unittest.TestCase): + def test_refresh_uses_control_connection_host_id_for_versions(self): + metadata = Metadata() + host_id = uuid.uuid4() + host = Host( + UnixSocketEndPoint('/tmp/maintenance.sock'), + SimpleConvictionPolicy, + host_id=host_id) + host.release_version = '3.11.0' + metadata.add_or_return_host(host) + + connection = Mock() + connection.endpoint = DefaultEndPoint('192.168.1.0') + connection.original_endpoint = connection.endpoint + connection._control_connection_host_id = host_id + parser = Mock() + parser.get_all_keyspaces.return_value = () + + with patch('cassandra.metadata.get_schema_parser', + return_value=parser) as get_parser: + metadata.refresh(connection, 0.1) + + get_parser.assert_called_once_with( + connection, '3.11.0', None, 0.1, None, None) + def test_reads_versions_from_system_local_when_missing(self): connection = Mock() diff --git a/tests/unit/test_shard_aware.py b/tests/unit/test_shard_aware.py index af27a84011..5c0b06c25d 100644 --- a/tests/unit/test_shard_aware.py +++ b/tests/unit/test_shard_aware.py @@ -21,7 +21,8 @@ from cassandra.cluster import ShardAwareOptions from cassandra.pool import HostConnection, HostDistance -from cassandra.connection import ShardingInfo, DefaultEndPoint +from cassandra.connection import (ShardingInfo, DefaultEndPoint, + UnixSocketEndPoint) from cassandra.metadata import Murmur3Token from cassandra.protocol_features import ProtocolFeatures from cassandra.shard_info import _ShardingInfo @@ -167,6 +168,40 @@ def test_advanced_shard_aware_port(self): finally: session.cluster.executor.shutdown(wait=True) + def test_unix_socket_bypasses_advanced_shard_aware_port(self): + endpoint = UnixSocketEndPoint('/tmp/maintenance.sock') + host = MagicMock() + host.endpoint = endpoint + session = MockSession() + pending = [] + + def submit(fn, *args, **kwargs): + pending.append((fn, args, kwargs)) + + session.submit = submit + connection_factory = MagicMock( + side_effect=session.mock_connection_factory) + session.cluster.connection_factory = connection_factory + + try: + pool = HostConnection( + host=host, host_distance=HostDistance.REMOTE, + session=session) + while pending: + fn, args, kwargs = pending.pop(0) + fn(*args, **kwargs) + + assert pool._get_shard_aware_endpoint() is None + assert set(pool._connections) == {0, 1, 2, 3} + assert connection_factory.call_count == 4 + for factory_call in connection_factory.call_args_list: + args, kwargs = factory_call + assert args[0] is endpoint + assert 'shard_id' not in kwargs + assert 'total_shards' not in kwargs + finally: + session.cluster.executor.shutdown(wait=True) + def test_ssl_advanced_shard_aware_port_requires_ssl_port(self): """ Test that SSL connections do not fall back to the plaintext From aa1915d3e53f43f6cbcde1d845fe37b3b8cfe098 Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Thu, 26 Mar 2026 12:06:14 +0200 Subject: [PATCH 168/200] fix: make HashableMock thread-safe by restoring __hash__ after MagicMixin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NonCallableMagicMock.__init__ (via MagicMixin) replaces __hash__ on the type with a MagicMock object. That MagicMock is not thread-safe, so concurrent hash() calls — e.g. `connection in self._trash` in pool.py return_connection — can raise `TypeError: __hash__ method should return an integer` under concurrent access on Windows. The previous __hash__ override was dead code: MagicMixin.__init__ always replaced it with a MagicMock before any test could call it. Fix by restoring a plain function as the class-level __hash__ after super().__init__ runs, so hash() always resolves to a real function instead of a thread-unsafe MagicMock callable. Fixes flaky test_successful_wait_for_connection on Windows CI. --- tests/unit/util.py | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/tests/unit/util.py b/tests/unit/util.py index 042f07fb99..74919d5ba4 100644 --- a/tests/unit/util.py +++ b/tests/unit/util.py @@ -32,6 +32,34 @@ def _check_order_consistency(smaller, bigger, equal=False): class HashableMock(NonCallableMagicMock): + """A Mock subclass that is safely hashable and usable in sets/dicts. - def __hash__(self): - return id(self) \ No newline at end of file + NonCallableMagicMock's __init__ (via MagicMixin) replaces __hash__ + on the *type* with a MagicMock object. That MagicMock is not + thread-safe, so concurrent hash() calls on the same instance — + e.g. ``connection in self._trash`` in pool.py — can raise + ``TypeError: __hash__ method should return an integer`` on Windows. + + We fix this by restoring a plain function as the class-level + __hash__ after super().__init__ runs, so hash() always resolves to + a real function (id-based) instead of a MagicMock callable. + + Note: NonCallableMock.__new__ already gives every mock instance its + own private subclass (see cpython unittest/mock.py) specifically so + that per-instance magic-method patching doesn't leak across mocks. + ``type(self)`` here is therefore that private, instance-specific + subclass rather than the shared ``HashableMock`` class, so this + assignment can't race with another ``HashableMock`` instance's + __init__ call. Once __init__ returns, __hash__ is a plain function + for the remaining lifetime of the instance, so it is safe to call + from multiple threads with no further synchronization needed. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Restore a real __hash__ after MagicMixin overwrites it. + type(self).__hash__ = HashableMock._id_hash + + @staticmethod + def _id_hash(self): + return id(self) From aa380a47d87e8e36115094dc47a9faf9598ad01d Mon Sep 17 00:00:00 2001 From: Israel Fruchter Date: Sun, 6 Sep 2026 16:00:14 +0300 Subject: [PATCH 169/200] CI: bump cibuildwheel to 4.2.1 so CPython 3.15 wheels are built cibuildwheel started building cp315 by default in 4.2.0, without needing the "cpython-prerelease" enable set: it pins a 3.15 release candidate, which upstream guarantees is ABI compatible with the final release. 3.2.1 knows nothing about 3.15, so no amount of configuration would have produced those wheels. 4.x dropped everything below Python 3.9 and no longer builds PyPy unless `enable` asks for it, which leaves four dead entries in our `skip` list that 4.2.1 warns about: `cp38*` and `pp38*` name versions it no longer knows, `pp39*` names a PyPy it does not enable, and `pp3*t-*` matches nothing, as there are no free-threaded PyPy builds. All four are removed. `cp39*` stays, since 3.9 is still a build target we opt out of, and so does `cp3*t-*`, which is what keeps the free-threaded CPython wheels out of the set. Nothing else in the configuration needs to change: `build = ["cp3*"]` already selects cp315, PyPy is already covered by `enable = ["pypy"]`, and 32-bit Linux, which 4.0.0 stopped building by default, was never built here anyway. Comparing `cibuildwheel --print-build-identifiers` between 3.2.1 and 4.2.1 for linux, macos and windows confirms the only difference is the added cp315 target; PyPy stays at pp311 and no new architecture or free-threaded identifier appears. The aarch64 invocation, which overrides CIBW_BUILD, was checked the same way, and neither emits warnings. --- .github/workflows/lib-build.yml | 2 +- pyproject.toml | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/lib-build.yml b/.github/workflows/lib-build.yml index f81dc4bcb1..2cda50ad52 100644 --- a/.github/workflows/lib-build.yml +++ b/.github/workflows/lib-build.yml @@ -102,7 +102,7 @@ jobs: - name: Install cibuildwheel run: | - uv tool install 'cibuildwheel==3.2.1' + uv tool install 'cibuildwheel==4.2.1' - name: Install Conan if: runner.os == 'Windows' diff --git a/pyproject.toml b/pyproject.toml index 2b740f9292..040397d10d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -152,12 +152,8 @@ directory = "htmlcov" build-frontend = "build[uv]" environment = { CASS_DRIVER_BUILD_CONCURRENCY = "2", CASS_DRIVER_BUILD_EXTENSIONS_ARE_MUST = "yes", CFLAGS = "-g0 -O3" } skip = [ - "cp38*", - "pp38*", "cp39*", - "pp39*", "cp3*t-*", - "pp3*t-*", "*i686", "*win32", "*musllinux*", From 48ccc2299e05893926be30d2121737e9e02d0a03 Mon Sep 17 00:00:00 2001 From: Israel Fruchter Date: Sun, 6 Sep 2026 16:00:23 +0300 Subject: [PATCH 170/200] Add support for Python 3.15 Declare the 3.15 classifier, run the integration tests on 3.15 and on free-threaded 3.15t, and say so in the docs. With the cibuildwheel bump in place this is all that publishing 3.15 wheels needs; no driver code change was required. The matrix rows mirror the 3.14 ones, including the asyncore exclusions: asyncore was removed from the standard library in 3.12, so only 3.11 still runs that event loop manager. As for 3.14, free-threaded wheels are not published - 3.15t is tested but `skip = ["cp3*t-*"]` keeps it out of the wheel set. Verified by running the unit suite on both 3.15 and 3.15t (953 passed, 112 skipped on each) and by checking that the Cython-generated extensions compile under 3.15. --- .github/workflows/integration-tests.yml | 6 +++++- CHANGELOG.rst | 4 ++++ README.rst | 2 +- docs/index.rst | 2 +- docs/installation.rst | 2 +- pyproject.toml | 1 + 6 files changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 25ef4ff100..64037d9a96 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -43,7 +43,7 @@ jobs: fail-fast: false matrix: java-version: [8] - python-version: ["3.11", "3.12", "3.13", "3.14", "3.14t"] + python-version: ["3.11", "3.12", "3.13", "3.14", "3.14t", "3.15", "3.15t"] event_loop_manager: ["libev", "asyncio", "asyncore"] exclude: - python-version: "3.12" @@ -54,6 +54,10 @@ jobs: event_loop_manager: "asyncore" - python-version: "3.14t" event_loop_manager: "asyncore" + - python-version: "3.15" + event_loop_manager: "asyncore" + - python-version: "3.15t" + event_loop_manager: "asyncore" steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 26725cfedc..bd2f2b28be 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -75,6 +75,10 @@ Others ``protocol_features`` could silently omit fields a negotiated extension requires. This release emits no new bytes on the wire; the parameter is groundwork for upcoming protocol extensions (``SCYLLA_USE_METADATA_ID``, ``TABLETS_ROUTING_V2``). +* Python 3.15 is now supported: wheels are published for it (cibuildwheel builds + ``cp315`` since 4.2.0, against a release candidate that is ABI compatible with the + final release) and the integration tests run on 3.15 and on free-threaded 3.15t. + As for 3.14, no free-threaded wheels are published; ``3.15t`` is tested only. 3.29.11 ======= diff --git a/README.rst b/README.rst index 84ceb443a3..3dc2269866 100644 --- a/README.rst +++ b/README.rst @@ -20,7 +20,7 @@ Scylla Enterprise (2018.1.x+) using exclusively Cassandra's binary protocol and .. image:: https://github.com/scylladb/python-driver/actions/workflows/integration-tests.yml/badge.svg?branch=master :target: https://github.com/scylladb/python-driver/actions/workflows/integration-tests.yml?query=event%3Apush+branch%3Amaster -The driver supports Python versions 3.10-3.14. +The driver supports Python versions 3.10-3.15. .. **Note:** This driver does not support big-endian systems. diff --git a/docs/index.rst b/docs/index.rst index cd137917d9..9762a8dc3e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -4,7 +4,7 @@ A Python client driver for `Scylla `_. This driver works exclusively with the Cassandra Query Language v3 (CQL3) and Cassandra's native protocol. -The driver supports Python 3.10-3.14. +The driver supports Python 3.10-3.15. This driver is open source under the `Apache v2 License `_. diff --git a/docs/installation.rst b/docs/installation.rst index 415fb5c316..0e5be73ea8 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -3,7 +3,7 @@ Installation Supported Platforms ------------------- -Python versions 3.10-3.14 are supported. Both CPython (the standard Python +Python versions 3.10-3.15 are supported. Both CPython (the standard Python implementation) and `PyPy `_ are supported and tested. Linux, OSX, and Windows are supported. diff --git a/pyproject.toml b/pyproject.toml index 040397d10d..845a4a74ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ classifiers = [ 'Programming Language :: Python :: 3.12', 'Programming Language :: Python :: 3.13', 'Programming Language :: Python :: 3.14', + 'Programming Language :: Python :: 3.15', 'Programming Language :: Python :: Free Threading :: 2 - Beta', 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy', From 695a68932f9dfc14293e4b4c868e367740d93244 Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Thu, 10 Sep 2026 23:22:34 -0400 Subject: [PATCH 171/200] control-connection: restore local address metadata --- cassandra/cluster.py | 8 +++- cassandra/pool.py | 8 ++-- tests/integration/standard/test_metadata.py | 15 ++++--- tests/unit/test_control_connection.py | 44 +++++++++++++++++---- 4 files changed, 55 insertions(+), 20 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index d858f5835e..a87b2d2082 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -3767,8 +3767,11 @@ class ControlConnection(object): _SELECT_PEERS = "SELECT peer, data_center, host_id, rack, release_version, rpc_address, schema_version, tokens FROM system.peers" _SELECT_PEERS_NO_TOKENS_TEMPLATE = "SELECT host_id, peer, data_center, rack, rpc_address, {nt_col_name}, release_version, schema_version FROM system.peers" - _SELECT_LOCAL = "SELECT broadcast_address, cluster_name, data_center, host_id, listen_address, partitioner, rack, release_version, rpc_address, schema_version, tokens FROM system.local WHERE key='local'" - _SELECT_LOCAL_NO_TOKENS = "SELECT host_id, cluster_name, data_center, rack, partitioner, release_version, schema_version, rpc_address FROM system.local WHERE key='local'" + _SELECT_LOCAL_COLUMNS = ("broadcast_address, cluster_name, data_center, host_id, " + "listen_address, partitioner, rack, release_version, " + "rpc_address, schema_version") + _SELECT_LOCAL = "SELECT {0}, tokens FROM system.local WHERE key='local'".format(_SELECT_LOCAL_COLUMNS) + _SELECT_LOCAL_NO_TOKENS = "SELECT {0} FROM system.local WHERE key='local'".format(_SELECT_LOCAL_COLUMNS) # Used only when token_metadata_enabled is set to False _SELECT_LOCAL_NO_TOKENS_RPC_ADDRESS = "SELECT rpc_address FROM system.local WHERE key='local'" @@ -4217,6 +4220,7 @@ def _refresh_node_list_and_token_map(self, connection, preloaded_results=None, host.dse_workloads = row.get("workloads") if row is local_row: + host.listen_address = row.get("listen_address") connection._control_connection_host_id = host_id tokens = row.get("tokens", None) diff --git a/cassandra/pool.py b/cassandra/pool.py index 2cd376d293..14829ffa26 100644 --- a/cassandra/pool.py +++ b/cassandra/pool.py @@ -62,8 +62,7 @@ class Host(object): 'system.local.broadcast_address' or 'system.peers.peer' (Cassandra 2-3) 'system.local.broadcast_address' or 'system.peers_v2.peer' (Cassandra 4) - This is not present in the ``system.local`` table for older versions of Cassandra. It - is also not queried if :attr:`~.Cluster.token_metadata_enabled` is ``False``. + This is not present in the ``system.local`` table for older versions of Cassandra. """ broadcast_port = None @@ -98,9 +97,8 @@ class Host(object): 'system.local.listen_address' - This is only available in the ``system.local`` table for newer versions of Cassandra. It is also not - queried if :attr:`~.Cluster.token_metadata_enabled` is ``False``. Usually the same as ``broadcast_address`` - unless configured differently in cassandra.yaml. + This is only available in the ``system.local`` table for newer versions of Cassandra. Usually the + same as ``broadcast_address`` unless configured differently in cassandra.yaml. """ listen_port = None diff --git a/tests/integration/standard/test_metadata.py b/tests/integration/standard/test_metadata.py index 562f457a32..0824d70511 100644 --- a/tests/integration/standard/test_metadata.py +++ b/tests/integration/standard/test_metadata.py @@ -73,17 +73,15 @@ def test_host_addresses(self): assert host.broadcast_rpc_port is not None con = self.cluster.control_connection.get_connections()[0] - local_host = con.host + local_host = self.cluster.control_connection._get_host_for_connection(con) # The control connection node should have the listen address set. - # Note: Scylla does not populate listen_address in system.local - if SCYLLA_VERSION is None: - listen_addrs = [host.listen_address for host in self.cluster.metadata.all_hosts()] - assert local_host in listen_addrs + assert local_host is not None + assert local_host.listen_address is not None # The control connection node should have the broadcast_rpc_address set. rpc_addrs = [host.broadcast_rpc_address for host in self.cluster.metadata.all_hosts()] - assert local_host in rpc_addrs + assert con.host in rpc_addrs @unittest.skipUnless( os.getenv('MAPPED_CASSANDRA_VERSION', None) is not None, @@ -160,6 +158,11 @@ def test_schema_metadata_disable(self): no_token = TestCluster(token_metadata_enabled=False) no_token_session = no_token.connect() assert len(no_token.metadata.token_map.token_to_host_owner) == 0 + no_token_connection = no_token.control_connection.get_connections()[0] + no_token_local_host = no_token.control_connection._get_host_for_connection(no_token_connection) + assert no_token_local_host is not None + assert no_token_local_host.listen_address is not None + assert no_token_local_host.broadcast_address is not None # Do a simple query to ensure queries are working query = "SELECT * FROM system.local WHERE key='local'" diff --git a/tests/unit/test_control_connection.py b/tests/unit/test_control_connection.py index dec61dacdc..13a68c7e55 100644 --- a/tests/unit/test_control_connection.py +++ b/tests/unit/test_control_connection.py @@ -154,8 +154,8 @@ def __init__(self): self.endpoint = DefaultEndPoint("192.168.1.0") self.original_endpoint = self.endpoint self.local_results = [ - ["rpc_address", "schema_version", "cluster_name", "data_center", "rack", "partitioner", "release_version", "tokens", "host_id"], - [["192.168.1.0", "a", "foocluster", "dc1", "rack1", "Murmur3Partitioner", "2.2.0", ["0", "100", "200"], "uuid1"]] + ["rpc_address", "schema_version", "cluster_name", "data_center", "rack", "partitioner", "release_version", "tokens", "host_id", "listen_address"], + [["192.168.1.0", "a", "foocluster", "dc1", "rack1", "Murmur3Partitioner", "2.2.0", ["0", "100", "200"], "uuid1", "192.168.1.0"]] ] self.peer_results = [ @@ -187,15 +187,15 @@ def sleep(self, amount): class ControlConnectionTest(unittest.TestCase): _matching_schema_preloaded_results = _node_meta_results( - local_results=(["rpc_address", "schema_version", "cluster_name", "data_center", "rack", "partitioner", "release_version", "tokens", "host_id"], - [["192.168.1.0", "a", "foocluster", "dc1", "rack1", "Murmur3Partitioner", "2.2.0", ["0", "100", "200"], "uuid1"]]), + local_results=(["rpc_address", "schema_version", "cluster_name", "data_center", "rack", "partitioner", "release_version", "tokens", "host_id", "listen_address"], + [["192.168.1.0", "a", "foocluster", "dc1", "rack1", "Murmur3Partitioner", "2.2.0", ["0", "100", "200"], "uuid1", "192.168.1.0"]]), peer_results=(["rpc_address", "peer", "schema_version", "data_center", "rack", "tokens", "host_id"], [["192.168.1.1", "10.0.0.1", "a", "dc1", "rack1", ["1", "101", "201"], "uuid2"], ["192.168.1.2", "10.0.0.2", "a", "dc1", "rack1", ["2", "102", "202"], "uuid3"]])) _nonmatching_schema_preloaded_results = _node_meta_results( - local_results=(["rpc_address", "schema_version", "cluster_name", "data_center", "rack", "partitioner", "release_version", "tokens", "host_id"], - [["192.168.1.0", "a", "foocluster", "dc1", "rack1", "Murmur3Partitioner", "2.2.0", ["0", "100", "200"], "uuid1"]]), + local_results=(["rpc_address", "schema_version", "cluster_name", "data_center", "rack", "partitioner", "release_version", "tokens", "host_id", "listen_address"], + [["192.168.1.0", "a", "foocluster", "dc1", "rack1", "Murmur3Partitioner", "2.2.0", ["0", "100", "200"], "uuid1", "192.168.1.0"]]), peer_results=(["rpc_address", "peer", "schema_version", "data_center", "rack", "tokens", "host_id"], [["192.168.1.1", "10.0.0.1", "a", "dc1", "rack1", ["1", "101", "201"], "uuid2"], ["192.168.1.2", "10.0.0.2", "b", "dc1", "rack1", ["2", "102", "202"], "uuid3"]])) @@ -354,6 +354,36 @@ def test_refresh_nodes_and_tokens(self): assert self.connection.wait_for_responses.call_count == 1 + def test_refresh_sets_local_listen_address_when_rpc_address_changes(self): + self.connection.local_results[1][0][0] = '192.168.1.4' + + self.control_connection.refresh_node_list_and_token_map() + + local_host = self.cluster.metadata.get_host_by_host_id('uuid1') + assert local_host.endpoint == DefaultEndPoint('192.168.1.4') + assert local_host.listen_address == '192.168.1.0' + + def test_refresh_sets_local_addresses_without_token_metadata(self): + self.control_connection._token_meta_enabled = False + self.connection.local_results[0].append('broadcast_address') + self.connection.local_results[1][0].append('10.0.0.1') + + for results in (self.connection.local_results, self.connection.peer_results): + tokens_index = results[0].index('tokens') + results[0].pop(tokens_index) + for row in results[1]: + row.pop(tokens_index) + self.control_connection.refresh_node_list_and_token_map() + + local_query = self.connection.wait_for_responses.call_args[0][1] + local_projection = local_query.query.split(" FROM system.local", 1)[0] + assert 'listen_address' in local_projection + assert 'broadcast_address' in local_projection + assert 'tokens' not in local_projection + local_host = self.cluster.metadata.get_host_by_host_id('uuid1') + assert local_host.listen_address == '192.168.1.0' + assert local_host.broadcast_address == '10.0.0.1' + def test_refresh_uses_control_endpoint_for_local_unix_host(self): maintenance_endpoint = UnixSocketEndPoint('/tmp/maintenance.sock') self._forget_local_host() @@ -394,7 +424,7 @@ def test_refresh_preserves_known_unix_endpoint_when_host_becomes_peer(self): self.connection.local_results[0], [['192.168.1.1', 'a', 'foocluster', 'dc1', 'rack1', 'Murmur3Partitioner', '2.2.0', ['1', '101', '201'], - 'uuid2']]) + 'uuid2', '192.168.1.1']]) peer_results = ( self.connection.peer_results[0], [['192.168.1.0', '10.0.0.1', 'a', 'dc1', 'rack1', From d0d5378d672ba03fedb642965d27377b9544cceb Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:56:05 +0000 Subject: [PATCH 172/200] Update actions/setup-java action to v5.3.0 --- .github/workflows/coverage.yml | 2 +- .github/workflows/integration-tests.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index ed28084d0c..2b4cd6b936 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -49,7 +49,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up JDK 8 - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 with: java-version: 8 distribution: 'adopt' diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 64037d9a96..5d9c6a38b2 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -63,7 +63,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up JDK ${{ matrix.java-version }} - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 with: java-version: ${{ matrix.java-version }} distribution: 'adopt' From f0004a8c3e42eb2a38e6c69b7840e4102643c185 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 00:58:17 +0000 Subject: [PATCH 173/200] Update dependency hatchling to v1.32.4 --- docs/pyproject.toml | 4 ++-- docs/uv.lock | 18 ++++++++++++++---- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/docs/pyproject.toml b/docs/pyproject.toml index d5205a599b..0ca0a61706 100644 --- a/docs/pyproject.toml +++ b/docs/pyproject.toml @@ -22,14 +22,14 @@ dependencies = [ [dependency-groups] # Add any dev-only tools here; example shown -dev = ["hatchling==1.29.0"] +dev = ["hatchling==1.32.4"] [tool.uv.sources] # Keep the driver editable from the parent directory scylla-driver = { path = "../", editable = true } [build-system] -requires = ["hatchling==1.29.0"] +requires = ["hatchling==1.32.4"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] diff --git a/docs/uv.lock b/docs/uv.lock index 8a56de64fa..ac1216472d 100644 --- a/docs/uv.lock +++ b/docs/uv.lock @@ -283,17 +283,18 @@ wheels = [ [[package]] name = "hatchling" -version = "1.29.0" +version = "1.32.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, { name = "pathspec" }, { name = "pluggy" }, + { name = "tomlkit" }, { name = "trove-classifiers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cf/9c/b4cfe330cd4f49cff17fd771154730555fa4123beb7f292cf0098b4e6c20/hatchling-1.29.0.tar.gz", hash = "sha256:793c31816d952cee405b83488ce001c719f325d9cda69f1fc4cd750527640ea6", size = 55656, upload-time = "2026-02-23T19:42:06.539Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/97/b5312f01a8c6daf729a9d272dd442e0c546dbcc630495788786c4b567ed0/hatchling-1.32.4.tar.gz", hash = "sha256:c4468f73144c054d2aab4ef0f0378c43b9878bf07f8ffd6b79690e970d375f07", size = 58065, upload-time = "2026-09-20T22:48:45.398Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/8a/44032265776062a89171285ede55a0bdaadc8ac00f27f0512a71a9e3e1c8/hatchling-1.29.0-py3-none-any.whl", hash = "sha256:50af9343281f34785fab12da82e445ed987a6efb34fd8c2fc0f6e6630dbcc1b0", size = 76356, upload-time = "2026-02-23T19:42:05.197Z" }, + { url = "https://files.pythonhosted.org/packages/5f/80/91f51f439c05d4ec4623c22928ce16a938d6d793bf709477830823497859/hatchling-1.32.4-py3-none-any.whl", hash = "sha256:08ecf7548fb48205e7f213d70c71e67b8271b7242093dc3f1da578b42c734a2c", size = 78748, upload-time = "2026-09-20T22:48:44.19Z" }, ] [[package]] @@ -583,7 +584,7 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [{ name = "hatchling", specifier = "==1.29.0" }] +dev = [{ name = "hatchling", specifier = "==1.32.4" }] [[package]] name = "pyyaml" @@ -981,6 +982,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" }, ] +[[package]] +name = "tomlkit" +version = "0.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/96/e07752635b98536177fa1f37671c8f3cdde2e724c6bcf6034b2cfb571565/tomlkit-0.15.1.tar.gz", hash = "sha256:e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97", size = 180129, upload-time = "2026-07-17T01:48:04.562Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl", hash = "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304", size = 49449, upload-time = "2026-07-17T01:48:05.728Z" }, +] + [[package]] name = "tornado" version = "6.5.7" From 5cd6e141909bb6227317a18dee56c0334e567818 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:35:46 +0000 Subject: [PATCH 174/200] Update actions/cache action to v6 --- .github/workflows/coverage.yml | 2 +- .github/workflows/integration-tests.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 2b4cd6b936..5d1629f841 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -66,7 +66,7 @@ jobs: run: uv sync - name: Cache Scylla download - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: ~/.ccm/repository key: scylla-${{ env.SCYLLA_VERSION }}-${{ runner.os }} diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 5d9c6a38b2..565928800f 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -82,7 +82,7 @@ jobs: run: uv sync - name: Cache Scylla download - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: ~/.ccm/repository key: scylla-${{ env.SCYLLA_VERSION }}-${{ runner.os }} From b3c82a440760a8855031951e866fc2eb48ef8cf4 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Fri, 11 Sep 2026 11:08:07 +0200 Subject: [PATCH 175/200] Add SSLSessionCache and per-endpoint TLS session cache keys TLS clients can skip the expensive part of a handshake by replaying a session established earlier with the same peer (RFC 5077 tickets for TLS 1.2, RFC 8446 PSKs for TLS 1.3), but OpenSSL never does this on its own: the client has to hold on to the session and offer it explicitly on the next connection. Add the storage half of that: a bounded, thread-safe LRU of TLS sessions keyed by TLS peer identity, plus an EndPoint.tls_session_cache_key property that produces the key. The cache is a module of its own, cassandra.ssl_session_cache, because keeping sessions is all it does: what may be offered to whom, and for how long, belongs with the connections that work it out. A cached session is not consumed by being used -- one session can be replayed by any number of concurrent connections -- so get() leaves the entry in place and each successful handshake stores back over it whatever the peer handed over. Entries carry the lifetime the caller gives them and are dropped once it runs out, so a session is never offered past the point the peer said it would honour it; what that lifetime should be is for the caller to work out, since it depends on how the session resumes. Whether a store moves an existing deadline is decided here rather than asked of the caller: a session the peer handed back unchanged keeps the deadline it had, since its lifetime runs from when the peer issued it and not from when it was last replayed, and re-stamping a full lifetime on every reuse would let one ticket be offered for as long as connections keep being opened. Below TLS 1.3 an abbreviated handshake hands back the offered session itself, so that is the ordinary case; TLS 1.3 normally issues a fresh one, which is entitled to a lifetime of its own. The two are told apart by session id, compared under the same lock that stores the result, so no concurrent store can land in between. An entry recognised this way keeps the object holding it as well as its deadline. SSLSocket.session builds a new wrapper on every access, so what a resumed connection stores back is another handle on the credential already there; swapping one for the other would change nothing but the identity a caller compares against when it asks for its own session to be dropped. Keeping it means an entry changes identity only when it changes credential. A caller can also say which session it offered on the handshake it is storing the result of, and a store of that same session is skipped where the entry no longer holds it: another connection has stored a session the peer reissued to it, or the deadline passed and a lookup dropped the entry. In neither case has this caller anything to add, while storing it would put a deadline running from now on a session the peer issued at some earlier point -- and would replace a fresher session with an older one. Neither comparison can say anything about a ticket whose session id is empty, which RFC 5077 section 3.4 lets a server send and for which SSLSession offers no ticket to compare instead. Two of those are reported as the same session, the conservative reading: the deadline then stays where it is, where calling them different would re-stamp a full lifetime on what may well be the ticket already held. What that costs is resumption rather than correctness -- a reissued ticket inherits its predecessor's deadline, and one arriving where the entry has since gone is not stored, which the next connection puts right by offering nothing and storing afresh. SNI endpoints add the server name to their key, since they all share a proxy address and port but are distinct TLS peers. Client-routes endpoints key on the node's host_id rather than the proxy address they happen to resolve to at the moment. Nothing uses the cache yet. Refs DRIVER-165 --- cassandra/connection.py | 33 ++ cassandra/ssl_session_cache.py | 240 ++++++++++++++ docs/api/cassandra/ssl-session-cache.rst | 7 + docs/api/index.rst | 1 + tests/unit/test_endpoints.py | 70 +++- tests/unit/test_ssl_session_cache.py | 386 +++++++++++++++++++++++ 6 files changed, 736 insertions(+), 1 deletion(-) create mode 100644 cassandra/ssl_session_cache.py create mode 100644 docs/api/cassandra/ssl-session-cache.rst create mode 100644 tests/unit/test_ssl_session_cache.py diff --git a/cassandra/connection.py b/cassandra/connection.py index d0a75818b2..5d0c39ca13 100644 --- a/cassandra/connection.py +++ b/cassandra/connection.py @@ -173,6 +173,29 @@ def socket_family(self): """ return socket.AF_UNSPEC + _tls_session_cache_key_override = None + + @property + def tls_session_cache_key(self): + """ + A hashable value identifying the TLS peer this endpoint connects to, + used to look up cached TLS sessions (see + :class:`~cassandra.ssl_session_cache.SSLSessionCache`). Two endpoints + may share a key only if a + TLS session established with one is valid for the other. + + An endpoint built to reach a node that another one already describes -- + an alternate listener of the same server -- carries that node's key + here, so both share one cached session. Subclasses give their own + identity in :meth:`_default_tls_session_cache_key`. + """ + if self._tls_session_cache_key_override is not None: + return self._tls_session_cache_key_override + return self._default_tls_session_cache_key() + + def _default_tls_session_cache_key(self): + return (self.address, self.port) + def resolve(self): """ Resolve the endpoint to an address/port. This is called @@ -287,6 +310,11 @@ def port(self): def ssl_options(self): return self._ssl_options + def _default_tls_session_cache_key(self): + # Several SNI endpoints share a proxy address and port, but each one + # presents a different server_name and therefore a different TLS peer. + return (self.address, self.port, self._server_name) + def resolve(self): try: resolved_addresses = socket.getaddrinfo(self._proxy_address, self._port, @@ -465,6 +493,11 @@ def port(self) -> Optional[int]: def host_id(self) -> uuid.UUID: return self._host_id + def _default_tls_session_cache_key(self): + # The proxy address this endpoint resolves to may change between + # connections; the TLS peer is identified by the node behind it. + return (self._host_id, self._original_address, self._original_port) + def resolve(self) -> Tuple[str, int]: """ Resolve endpoint by delegating to the handler. diff --git a/cassandra/ssl_session_cache.py b/cassandra/ssl_session_cache.py new file mode 100644 index 0000000000..96af5fa219 --- /dev/null +++ b/cassandra/ssl_session_cache.py @@ -0,0 +1,240 @@ +# Copyright 2026 ScyllaDB, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Storage for TLS sessions, so that connections can resume one instead of +performing a full handshake. + +The policy around this -- what may be offered to whom, for how long, and what +a handshake hands back -- lives with the connections in +:mod:`cassandra.connection`; what is here is only the keeping of them. +""" + +import time +from collections import OrderedDict +from threading import Lock + + +class SSLSessionCache(object): + """ + A thread-safe, bounded cache of TLS sessions, keyed by TLS peer identity. + + TLS clients can skip the expensive part of a handshake by replaying a + session established earlier with the same peer (RFC 5077 session tickets + for TLS 1.2, RFC 8446 pre-shared keys for TLS 1.3). OpenSSL never does + this on its own -- the client has to hold on to the session and offer it + on the next connection -- so the driver keeps one of these caches per + :class:`~.Cluster` and reuses sessions across every connection it opens, + most importantly the burst of per-shard connections opened to a node at + once. + + A cached session is not consumed by being used: the same session can be + replayed by any number of concurrent connections, and each successful + handshake stores back whatever the peer handed over -- a fresh session + where one was issued, otherwise the same one again, which keeps the + deadline it already had rather than starting a new one. An entry whose + lifetime has run out is never handed out again, and is dropped when it + is looked up or when room is needed; entries otherwise go only by being + replaced or, once the cache is full, by having been used least + recently. A session the server declines for any other reason simply + results in a full handshake, which is what would have happened anyway. + + Instances are safe to use from multiple threads, and may be shared + between clusters -- which is what makes sessions outlive the cluster that + established them, so that a cluster replacing an earlier one resumes + instead of handshaking in full. A cache the driver created for a cluster + lives and dies with it; one supplied to :class:`~.Cluster` belongs to + whoever supplied it, and the driver removes an entry from it only to + replace it, because its lifetime ran out, or to make room. Note that an + entry keeps the ``SSLContext`` its session was established with alive -- + CPython's ``SSLSession`` holds a reference to it -- so a long-lived cache + holds the contexts of at most :attr:`max_size` peers. :meth:`clear` drops + everything, for a caller that wants them gone sooner. + """ + + def __init__(self, max_size=1024): + """ + :param max_size: maximum number of peers to keep sessions for. When + exceeded, the least recently used entry is evicted. + """ + # Anything but a positive integer is rejected outright rather than + # compared against: a float such as nan or inf would pass a `< 1` check + # and then leave the cache growing without bound, while True is an int + # that passes it and would quietly cap the cache at one entry. + if (not isinstance(max_size, int) or isinstance(max_size, bool) + or max_size < 1): + raise ValueError( + "max_size must be a positive integer, got %r" % (max_size,)) + self._max_size = max_size + self._sessions = OrderedDict() + self._lock = Lock() + + @property + def max_size(self): + """The maximum number of peers this cache keeps sessions for.""" + return self._max_size + + def get(self, key): + """ + Return the cached session for *key*, or :const:`None` if there is none + or its lifetime has run out. A session that is still live stays in the + cache; an expired one is dropped. + """ + with self._lock: + entry = self._sessions.get(key) + if entry is None: + return None + session, expires_at = entry + if expires_at is not None and time.monotonic() >= expires_at: + del self._sessions[key] + return None + self._sessions.move_to_end(key) + return session + + def set(self, key, session, lifetime=None, offered=None): + """ + Store *session* as the session to offer for *key*, replacing any + previous one. A :const:`None` session is ignored. + + A session the peer handed back unchanged keeps the deadline the entry + already had, rather than starting a new one: its lifetime runs from + when the peer issued it and not from when it was last replayed, so + re-stamping a full lifetime on every reuse would let one ticket be + offered for as long as connections keep being opened. Resuming below + TLS 1.3 is exactly that case -- an abbreviated handshake hands back the + session that was offered, same id and same ticket -- while TLS 1.3 + normally issues a fresh one, which starts its own lifetime. Comparing + the two here is what makes the rule hold: what is cached, what the + caller offered and what is replacing them are all read under the one + lock that also stores the result, so a connection storing concurrently + cannot land in between. + + :param lifetime: how much longer, in seconds, the session may be + offered. Once it has passed, the entry is dropped rather than + returned. :const:`None` means no limit, which callers should + reserve for sessions that carry no lifetime of their own. + :param offered: the session the caller offered on the handshake it is + storing the result of, if any. Storing that same session back when + the entry no longer holds it is not a new session arriving, and is + skipped: see below. + """ + if session is None: + return + expires_at = None if lifetime is None else time.monotonic() + lifetime + with self._lock: + previous = self._sessions.get(key) + if previous is not None and self._is_same_session(previous[0], session): + # The entry keeps both its deadline and the object holding it. + # ``SSLSocket.session`` builds a new wrapper on every access, so + # what arrives here for a session already cached is another + # handle on the same credential; swapping one for the other + # changes nothing except the identity that :meth:`discard` + # compares against, and a connection that offered the entry + # would then be unable to retract what it offered. + session, expires_at = previous + elif offered is not None and self._is_same_session(offered, session): + # The peer handed this caller back the very session it offered, + # but the entry no longer holds it: another connection opened + # alongside stored a session the peer reissued to it, or the + # deadline passed and a lookup dropped the entry. Either way + # this caller has nothing to add -- and storing it would put a + # deadline running from now on a session the peer issued at + # some earlier point, which is the one thing the rule above + # exists to prevent. + return + self._sessions[key] = (session, expires_at) + self._sessions.move_to_end(key) + if len(self._sessions) > self._max_size: + # Whose lifetime has run out and which was used least recently + # are independent once peers announce different lifetimes, so + # evicting purely by recency can drop a live entry and keep a + # dead one. Take the dead ones first. + self._drop_expired_unlocked() + while len(self._sessions) > self._max_size: + self._sessions.popitem(last=False) + + @staticmethod + def _is_same_session(cached, session): + """ + Whether *session* is the one already cached, so that the entry's + deadline is not a new store's to move. + + ``SSLSocket.session`` builds a new object on each access, so identity + cannot answer this on its own; a session id can, and is what tells a + ticket the peer reissued from the one it handed back -- including the + TLS 1.3 server that resumes without issuing one. + + An id of no length is a case of its own. RFC 5077 section 3.4 lets a + server issue a ticket and send an empty session id with it, and + ``SSLSession`` exposes no ticket to compare instead, so two such + sessions cannot be told apart at all. They are reported as the same + one, which is the conservative reading: the deadline then stays where + it is, where calling them different would re-stamp a full lifetime on + what may well be the ticket already held -- the one thing this + comparison exists to prevent. What that costs is resumption, never + correctness: a reissued ticket inherits its predecessor's deadline, and + one arriving where the entry has since gone is not stored at all, which + the next connection puts right by offering nothing and storing afresh. + An object carrying no id at all is not a session this can recognise, + and is taken to be new. + """ + if cached is session: + return True + cached_id = getattr(cached, 'id', None) + return cached_id is not None and cached_id == getattr(session, 'id', None) + + def _drop_expired_unlocked(self): + now = time.monotonic() + for key in [key for key, (_, expires_at) in self._sessions.items() + if expires_at is not None and now >= expires_at]: + del self._sessions[key] + + def discard(self, key, session=None): + """ + Drop the session cached for *key*, if any. + + Give *session* to drop it only while that is still the cached one. A + caller acting on a session it read earlier needs this: by the time it + decides to drop it, another connection may have stored a session the + peer issued in its place, and that one is not the caller's to remove. + + The comparison is by identity, which :meth:`set` is what makes + dependable: a store of the session already cached keeps the object + that is there, so an entry changes identity only when it changes + credential. + """ + with self._lock: + entry = self._sessions.get(key) + if entry is None: + return + if session is not None and entry[0] is not session: + return + del self._sessions[key] + + def clear(self): + """Drop all cached sessions.""" + with self._lock: + self._sessions.clear() + + def __len__(self): + with self._lock: + return len(self._sessions) + + def __repr__(self): + # The size is read without the lock, unlike __len__: a repr has to be + # safe to take from inside the cache's own methods -- a log line + # formatting %r under the lock would otherwise wait for itself -- and a + # count that another thread has moved on from is no worse than one it + # moves on from a moment later. + return "<%s max_size=%d size=%d>" % ( + self.__class__.__name__, self._max_size, len(self._sessions)) diff --git a/docs/api/cassandra/ssl-session-cache.rst b/docs/api/cassandra/ssl-session-cache.rst new file mode 100644 index 0000000000..32aafec78e --- /dev/null +++ b/docs/api/cassandra/ssl-session-cache.rst @@ -0,0 +1,7 @@ +cassandra.ssl_session_cache +=========================== + +.. module:: cassandra.ssl_session_cache + +.. autoclass:: SSLSessionCache + :members: diff --git a/docs/api/index.rst b/docs/api/index.rst index f63534e532..6b921ffbf2 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -19,6 +19,7 @@ Core Driver cassandra/decoder cassandra/concurrent cassandra/connection + cassandra/ssl-session-cache cassandra/util cassandra/timestamps cassandra/io/asyncioreactor diff --git a/tests/unit/test_endpoints.py b/tests/unit/test_endpoints.py index 1b6367dc2d..87d487945f 100644 --- a/tests/unit/test_endpoints.py +++ b/tests/unit/test_endpoints.py @@ -9,8 +9,10 @@ import unittest import itertools +import uuid -from cassandra.connection import DefaultEndPoint, SniEndPointFactory +from cassandra.connection import (ClientRoutesEndPoint, DefaultEndPoint, + SniEndPointFactory, UnixSocketEndPoint) from unittest.mock import patch @@ -53,3 +55,69 @@ def test_endpoint_resolve(self): for i in range(10): (address, _) = endpoint.resolve() assert address == next(it) + + def test_tls_session_cache_key_distinguishes_server_names(self): + # All SNI endpoints behind a proxy share an address and port, so the + # server name has to be part of the key or they would share sessions. + one = self.endpoint_factory.create_from_sni('node1') + other = self.endpoint_factory.create_from_sni('node2') + + assert one.tls_session_cache_key != other.tls_session_cache_key + assert one.tls_session_cache_key == \ + self.endpoint_factory.create_from_sni('node1').tls_session_cache_key + assert one.tls_session_cache_key != DefaultEndPoint( + 'proxy.datastax.com', 30002).tls_session_cache_key + + +class TlsSessionCacheKeyTest(unittest.TestCase): + + def test_default_endpoint_key(self): + assert DefaultEndPoint('10.0.0.1', 9042).tls_session_cache_key == ('10.0.0.1', 9042) + assert DefaultEndPoint('10.0.0.1', 9042).tls_session_cache_key != \ + DefaultEndPoint('10.0.0.1', 9142).tls_session_cache_key + + def test_unix_socket_endpoint_key(self): + assert UnixSocketEndPoint('/tmp/a').tls_session_cache_key != \ + UnixSocketEndPoint('/tmp/b').tls_session_cache_key + + def test_client_routes_endpoint_key_follows_the_node_not_the_route(self): + host_id = uuid.uuid4() + endpoint = ClientRoutesEndPoint(host_id, handler=None, + original_address='10.0.0.1', + original_port=9042) + other = ClientRoutesEndPoint(uuid.uuid4(), handler=None, + original_address='10.0.0.1', + original_port=9042) + + assert endpoint.tls_session_cache_key == (host_id, '10.0.0.1', 9042) + assert endpoint.tls_session_cache_key != other.tls_session_cache_key + + def test_an_override_replaces_the_endpoints_own_identity(self): + # An endpoint built to reach a node another one already describes -- the + # shard-aware port alias -- carries that node's key so both share one + # cached session. + node = DefaultEndPoint('10.0.0.1', 9042) + alias = DefaultEndPoint('10.0.0.1', 19142) + assert alias.tls_session_cache_key != node.tls_session_cache_key + + alias._tls_session_cache_key_override = node.tls_session_cache_key + + assert alias.tls_session_cache_key == node.tls_session_cache_key + + def test_an_override_applies_to_every_endpoint_type(self): + # The override lives on the base property, so a subclass that gives its + # own identity still honours it. + endpoints = [DefaultEndPoint('10.0.0.1'), + UnixSocketEndPoint('/tmp/a'), + ClientRoutesEndPoint(uuid.uuid4(), None, '10.0.0.1', 9042), + SniEndPointFactory("proxy", 30002).create_from_sni('node1')] + for endpoint in endpoints: + endpoint._tls_session_cache_key_override = ('the', 'node') + assert endpoint.tls_session_cache_key == ('the', 'node'), endpoint + + def test_keys_are_hashable(self): + # Keys are used as dict keys in SSLSessionCache. + for endpoint in (DefaultEndPoint('10.0.0.1'), + UnixSocketEndPoint('/tmp/a'), + ClientRoutesEndPoint(uuid.uuid4(), None, '10.0.0.1', 9042)): + hash(endpoint.tls_session_cache_key) diff --git a/tests/unit/test_ssl_session_cache.py b/tests/unit/test_ssl_session_cache.py new file mode 100644 index 0000000000..d8ae7e9934 --- /dev/null +++ b/tests/unit/test_ssl_session_cache.py @@ -0,0 +1,386 @@ +# Copyright 2026 ScyllaDB, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import threading +import time +import unittest +from concurrent.futures import ThreadPoolExecutor + +import pytest + +from cassandra.ssl_session_cache import SSLSessionCache + + +class _Session(object): + """ + Stands in for ssl.SSLSession, which only a real handshake can produce. + Only the session id matters here: it is how the cache tells a session the + peer reissued from the one it handed back unchanged. + """ + + def __init__(self, id=b'\x01' * 32): + self.id = id + + +class SSLSessionCacheTest(unittest.TestCase): + + def test_get_missing_key_returns_none(self): + assert SSLSessionCache().get(('10.0.0.1', 9042)) is None + + def test_set_then_get(self): + cache = SSLSessionCache() + session = object() + cache.set(('10.0.0.1', 9042), session) + + assert cache.get(('10.0.0.1', 9042)) is session + assert cache.get(('10.0.0.2', 9042)) is None + + def test_get_does_not_consume_the_session(self): + # Sessions are replayable: a burst of per-shard connections to one + # node must all be able to offer the same cached session. + cache = SSLSessionCache() + session = object() + cache.set(('10.0.0.1', 9042), session) + + assert [cache.get(('10.0.0.1', 9042)) for _ in range(10)] == [session] * 10 + assert len(cache) == 1 + + def test_set_replaces_the_previous_session(self): + cache = SSLSessionCache() + older, newer = object(), object() + cache.set(('10.0.0.1', 9042), older) + cache.set(('10.0.0.1', 9042), newer) + + assert cache.get(('10.0.0.1', 9042)) is newer + assert len(cache) == 1 + + def test_none_session_is_ignored(self): + cache = SSLSessionCache() + session = object() + cache.set(('10.0.0.1', 9042), session) + cache.set(('10.0.0.1', 9042), None) + + assert cache.get(('10.0.0.1', 9042)) is session + assert len(cache) == 1 + + def test_evicts_least_recently_used_key(self): + cache = SSLSessionCache(max_size=2) + first, second, third = object(), object(), object() + cache.set('first', first) + cache.set('second', second) + + # Touching 'first' makes 'second' the least recently used. + assert cache.get('first') is first + cache.set('third', third) + + assert len(cache) == 2 + assert cache.get('second') is None + assert cache.get('first') is first + assert cache.get('third') is third + + def test_set_refreshes_recency(self): + cache = SSLSessionCache(max_size=2) + cache.set('first', object()) + cache.set('second', object()) + cache.set('first', object()) + cache.set('third', object()) + + assert cache.get('second') is None + assert cache.get('first') is not None + + def test_expired_entry_is_not_returned_and_is_dropped(self): + cache = SSLSessionCache() + cache.set('key', object(), lifetime=-1) + + assert cache.get('key') is None + assert len(cache) == 0 + + def test_live_entry_is_returned(self): + cache = SSLSessionCache() + session = object() + cache.set('key', session, lifetime=3600) + + assert cache.get('key') is session + + def test_the_same_session_again_keeps_the_deadline_it_had(self): + # What a resumed handshake below TLS 1.3 stores back: the session that + # was offered, whose lifetime runs from when the peer issued it and not + # from when it was last replayed. + cache = SSLSessionCache() + cache.set('key', _Session(), lifetime=0.05) + cache.set('key', _Session(), lifetime=3600) + + time.sleep(0.06) + + assert cache.get('key') is None + + def test_a_reissued_session_starts_its_own_deadline(self): + # What TLS 1.3 normally stores back: a fresh ticket, which is entitled + # to the lifetime the peer announced with it. + cache = SSLSessionCache() + cache.set('key', _Session(b'first'), lifetime=-1) + reissued = _Session(b'second') + cache.set('key', reissued, lifetime=3600) + + assert cache.get('key') is reissued + + def test_the_offered_session_does_not_revive_an_entry_that_was_dropped(self): + # The deadline passed between the offer and the store, and a lookup + # dropped the entry. Storing the offered session back would put a full + # fresh lifetime on a session the peer issued long enough ago to have + # expired. + cache = SSLSessionCache() + session = _Session() + cache.set('key', session, lifetime=-1) + assert cache.get('key') is None + + cache.set('key', session, lifetime=3600, offered=session) + + assert cache.get('key') is None + assert len(cache) == 0 + + def test_the_offered_session_does_not_displace_a_siblings(self): + # Connections to one node are opened together: another may have stored + # a session the peer reissued to it between this one's offer and its + # store. That entry is fresher than what this caller has to say. + cache = SSLSessionCache() + offered, reissued = _Session(b'offered'), _Session(b'reissued') + cache.set('key', offered, lifetime=3600) + cache.set('key', reissued, lifetime=3600) + deadline = cache._sessions['key'][1] + + cache.set('key', offered, lifetime=3600, offered=offered) + + assert cache.get('key') is reissued + assert cache._sessions['key'][1] == deadline + + def test_a_reissued_session_still_replaces_a_siblings(self): + # The other side of it: what the peer issued to this connection is new, + # and is entitled to the lifetime announced with it. + cache = SSLSessionCache() + offered, theirs = _Session(b'offered'), _Session(b'theirs') + cache.set('key', theirs, lifetime=3600) + mine = _Session(b'mine') + + cache.set('key', mine, lifetime=3600, offered=offered) + + assert cache.get('key') is mine + + def test_tickets_that_carry_no_session_id_are_not_told_apart(self): + # RFC 5077 3.4 lets a server issue a ticket and send an empty session + # id with it, and SSLSession exposes no ticket to compare instead, so + # two of them are indistinguishable. Reading them as the same session + # keeps the deadline where it is; reading them as different would + # re-stamp a full lifetime on what may be the ticket already held. + cache = SSLSessionCache() + first, second = _Session(b''), _Session(b'') + cache.set('key', first, lifetime=3600) + deadline = cache._sessions['key'][1] + + cache.set('key', second, lifetime=7200) + + # One session as far as this can tell, so the entry keeps both the + # deadline it had and the object holding it. + assert cache.get('key') is first + assert cache._sessions['key'][1] == deadline + + def test_an_empty_id_costs_a_store_where_the_entry_has_gone(self): + # The other half of that reading, and the reason it is the safe one: + # the offered ticket and the one that came back cannot be told apart, + # so this store is skipped rather than reviving a deadline. The next + # connection offers nothing and caches whatever it is given. + cache = SSLSessionCache() + offered = _Session(b'') + cache.set('key', offered, lifetime=-1) + assert cache.get('key') is None + + cache.set('key', _Session(b''), lifetime=3600, offered=offered) + assert len(cache) == 0 + + fresh = _Session(b'') + cache.set('key', fresh, lifetime=3600) + assert cache.get('key') is fresh + + def test_an_object_carrying_no_id_is_taken_to_be_new(self): + # Distinct from an empty id: nothing the driver caches is in this + # position, since every SSLSession has the attribute. + cache = SSLSessionCache() + cache.set('key', object(), lifetime=-1) + session = object() + cache.set('key', session, lifetime=3600) + + assert cache.get('key') is session + + def test_a_lifetime_replaces_the_previous_one(self): + cache = SSLSessionCache() + cache.set('key', object(), lifetime=-1) + session = object() + cache.set('key', session, lifetime=3600) + + assert cache.get('key') is session + + def test_a_dead_entry_is_evicted_before_a_live_one(self): + # Whose lifetime has run out and which was used least recently are + # independent once peers announce different lifetimes. + cache = SSLSessionCache(max_size=3) + cache.set('live-1', 'A', lifetime=3600) + cache.set('live-2', 'B', lifetime=3600) + cache.set('expired', 'C', lifetime=-1) + + cache.set('fourth', 'D', lifetime=3600) + + assert cache.get('live-1') == 'A' + assert cache.get('live-2') == 'B' + assert cache.get('fourth') == 'D' + assert len(cache) == 3 + + def test_the_lru_still_goes_when_nothing_has_expired(self): + cache = SSLSessionCache(max_size=2) + cache.set('first', 'A', lifetime=3600) + cache.set('second', 'B', lifetime=3600) + + cache.set('third', 'C', lifetime=3600) + + assert cache.get('first') is None + assert cache.get('second') == 'B' + assert cache.get('third') == 'C' + + def test_a_dead_entry_lingers_until_it_is_looked_up_or_room_is_needed(self): + # Documented rather than swept eagerly: nothing walks the cache on a + # timer, so an entry nobody asks for and nobody needs room for stays. + cache = SSLSessionCache(max_size=8) + cache.set('expired', 'C', lifetime=-1) + + assert len(cache) == 1 + assert cache.get('expired') is None + assert len(cache) == 0 + + def test_discard(self): + cache = SSLSessionCache() + cache.set('key', object()) + cache.discard('key') + + assert cache.get('key') is None + assert len(cache) == 0 + cache.discard('key') # discarding what is not there is fine + + def test_storing_the_same_session_keeps_the_object_that_holds_it(self): + # SSLSocket.session builds a new wrapper on every access, so what a + # resumed connection stores back is another handle on the credential + # already cached. Keeping the one that is there is what lets the + # connection that offered it retract it: identity is how discard() + # tells its own session from one the peer issued in its place. + cache = SSLSessionCache() + offered = _Session() + cache.set('key', offered, lifetime=3600) + cache.set('key', _Session(), lifetime=3600) # a sibling resumes + + assert cache.get('key') is offered + + cache.discard('key', offered) + assert cache.get('key') is None + + def test_a_session_the_peer_issued_in_its_place_is_not_retractable(self): + # The other side of it: that one never failed anything, and every later + # connection would pay a full handshake for dropping it. + cache = SSLSessionCache() + offered = _Session(b'offered') + cache.set('key', offered, lifetime=3600) + reissued = _Session(b'reissued') + cache.set('key', reissued, lifetime=3600) + + cache.discard('key', offered) + + assert cache.get('key') is reissued + + def test_discard_of_a_named_session_spares_a_newer_one(self): + # A connection acting on a session it read earlier must not remove the + # fresh one another connection stored under the same key meanwhile. + cache = SSLSessionCache() + older, newer = object(), object() + cache.set('key', older) + cache.set('key', newer) + + cache.discard('key', older) + + assert cache.get('key') is newer + + def test_discard_of_a_named_session_removes_it_when_still_current(self): + cache = SSLSessionCache() + session = object() + cache.set('key', session) + + cache.discard('key', session) + + assert cache.get('key') is None + + def test_clear(self): + cache = SSLSessionCache() + cache.set('key', object()) + cache.clear() + + assert len(cache) == 0 + assert cache.get('key') is None + + def test_rejects_invalid_max_size(self): + # A float would pass a plain `< 1` check and then never bound the cache + # (nan and inf compare False against every limit), and True is an int + # that passes it and would cap the cache at a single entry. + for max_size in (0, -1, float('nan'), float('inf'), 2.5, '8', None, + True, False): + with pytest.raises(ValueError): + SSLSessionCache(max_size=max_size) + + def test_repr(self): + cache = SSLSessionCache(max_size=7) + cache.set('key', object()) + + assert repr(cache) == '' + + def test_repr_can_be_taken_while_the_cache_is_locked(self): + # So that a log line formatting %r from inside one of the cache's own + # methods does not wait for the lock that method is holding. + cache = SSLSessionCache(max_size=7) + cache.set('key', object()) + taken = [] + + def under_the_lock(): + with cache._lock: + taken.append(repr(cache)) + + # Daemon: if this ever does deadlock the assertion below reports it + # rather than the suite hanging at exit waiting for the thread. + thread = threading.Thread(target=under_the_lock, daemon=True) + thread.start() + thread.join(timeout=5) + + assert not thread.is_alive(), 'repr() deadlocked against the cache lock' + assert taken == [''] + + def test_concurrent_access_keeps_the_cache_bounded(self): + cache = SSLSessionCache(max_size=8) + + def hammer(worker): + for i in range(500): + key = (worker + i) % 32 + cache.set(key, object()) + cache.get(key) + assert len(cache) <= 8 + + # result() re-raises whatever a worker hit, with its own traceback. + with ThreadPoolExecutor(max_workers=8) as pool: + for future in [pool.submit(hammer, worker) for worker in range(8)]: + future.result() + + assert len(cache) <= 8 From a71ef96eefb8e06f98ef4bcdc4fe5604c9fb26a3 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Thu, 17 Sep 2026 16:07:56 +0200 Subject: [PATCH 176/200] Resume TLS sessions on new connections Offer the cached session for the endpoint before the handshake, and store the negotiated session once the connection is up, so that the next connection to the same node -- in particular the burst of per-shard connections a pool opens at once -- can skip the certificate exchange and signature of a full handshake. The session is stored from the ReadyMessage / AuthSuccessMessage handlers rather than right after the handshake. A TLS 1.3 server sends its NewSessionTicket as a post-handshake message, so a session read straight after connect() carries no ticket and would not resume; by the time the CQL handshake has completed the ticket has been read off the socket. Storing is idempotent, so nothing needs to track whether it already happened, and every failure in this path is logged and dropped: resumption is an optimisation, and both call sites are wrapped in @defunct_on_error, where a raised exception would kill a healthy connection. How long a session may be offered is worked out here, because it depends on how the session resumes: a ticket's lifetime is the one the server announced, while SSLSession.timeout is only the local context's default and says nothing about what the peer will still accept, so it is used solely for a session that resumes by id. RFC 8446 section 4.6.1 also caps the client at seven days however long the server asked for. A zero lifetime is read against the negotiated version, since the two RFCs disagree on it: TLS 1.3 says discard the ticket immediately, while RFC 5077 section 3.3 reserves zero for "lifetime unspecified" and leaves retention to local policy, so a TLS 1.2 ticket is kept and timed by the local timeout. The version also rules out caching a TLS 1.3 session that carries only an id: resumption there is the ticket's pre-shared key, while the id a TLS 1.3 handshake carries is the legacy_session_id_echo a server sends back for the middlebox compatibility mode of RFC 8446 appendix D.4, which resumes nothing. OpenSSL reports no id at all until a NewSessionTicket has been read, so that session is not one it produces; stating the rule against the negotiated version rather than against what one library exposes is what makes it hold regardless. OpenSSL does not apply either limit on the client's behalf -- it will offer an expired ticket and let the server refuse it. What the entry gets is what is left of the announced lifetime: the peer issued the ticket during the handshake, while the store happens a startup exchange and perhaps an authentication later, and that time is the peer's to spend rather than the entry's. The age is measured from a monotonic mark taken as the handshake began, not from SSLSession.time, which is a wall-clock stamp: subtracting that would let a clock step landing in between decide the answer -- far enough forward and nothing is cached at all, backward and the age disappears. The deadline the cache keeps is monotonic too, so nothing after the store can skew it either. A pool reaches a shard-aware node on a second port, which would otherwise key those connections separately from the one the control connection established, leaving the whole per-shard burst to handshake in full. The endpoint alias that _get_shard_aware_endpoint already builds for that port therefore carries the node's cache key, so both listeners share one session and nothing has to be threaded through the connection factory. The port stays part of the key by default, so two unrelated TLS servers on one address still cannot share a session; only an endpoint that names another node is exempt. The SSLContext is part of the key because a session cannot be replayed onto a different one and a cache may be shared by several clusters. It is held strongly there: a cached session already keeps its context alive on its own -- CPython's SSLSession holds a reference to the context it was established with -- so holding it weakly here would buy nothing. The key also carries the name wrap_socket() is given, which is the name the peer certificate is verified against. A resumed handshake sends no Certificate, so that name is never checked again; offering a session to a connection expecting a different name would silently skip hostname verification for it. Both the key and wrap_socket() take the name from one accessor so the two cannot drift apart. A session offered on a connection whose handshake then failed is dropped from the cache. Both RFCs have a server fall back to a full handshake rather than fail when it will not resume, so this should not happen; but nothing stores a fresh session for a connection that never came up, so an entry that did provoke a failure would otherwise be offered again by every later connection until its lifetime ran out. The handshake need not fail where it is started: ssl_options may carry do_handshake_on_connect=False, which leaves it to the first read or write and so to the reactor, and a failure there arrives at defunct() instead -- which retracts for the same reason and under the same rule. Only a TLS error counts: a refused or reset connection says nothing about the session. And only the session this connection offered goes: connections to one node are opened together, so another may have stored a session the peer issued in its place, and that one failed nothing. A sibling storing the same session back is not that, and leaves the entry retractable, because the cache keeps the object it already holds for a session it recognises. Nor does an endpoint that borrowed its key retract anything: the shard-aware listener resumes the node's session and stores back to it, which is the point of sharing the key, but the entry belongs to the endpoint that owns it. Dropping it there would cost the node's other pools and its control connection a handshake apiece, and a pool filling against that listener would do it again on every retry, while a session that genuinely cannot resume fails on the owner too and goes from there. What was offered is kept until the store, which hands it to the cache and then lets it go. Telling a session the peer reissued from the one that came back unchanged needs both sides of the exchange, and where connections to a node are opened together only the connection that offered one knows the second. Letting it go there is what bounds the retraction above: reaching the store means the CQL handshake completed, so the TLS one stood, and a TLS error long afterwards has nothing of this connection's to withdraw. Three accessors are the whole of what a reactor whose TLS does not go through the stdlib ssl module has to reimplement to take part: the policy around them asks one for the session to store, one for the negotiated version and one to restore a session onto a socket, and reads nothing off a socket itself. Connections whose SSLContext is derived from ssl_options do not participate, because a session cannot be replayed onto a different context and each of those connections builds its own. The asyncio reactor opts out entirely: its handshake happens inside loop.create_connection(), with no point at which a session could be restored. Refs DRIVER-165 --- cassandra/connection.py | 302 +++++++++++++++++- cassandra/io/asyncioreactor.py | 8 + cassandra/pool.py | 7 + tests/unit/test_connection.py | 566 ++++++++++++++++++++++++++++++++- tests/unit/test_shard_aware.py | 21 ++ 5 files changed, 895 insertions(+), 9 deletions(-) diff --git a/cassandra/connection.py b/cassandra/connection.py index 5d0c39ca13..2d7a5e9738 100644 --- a/cassandra/connection.py +++ b/cassandra/connection.py @@ -193,6 +193,14 @@ def tls_session_cache_key(self): return self._tls_session_cache_key_override return self._default_tls_session_cache_key() + @property + def _tls_session_cache_key_is_borrowed(self): + """ + Whether :attr:`tls_session_cache_key` names a node this endpoint is an + alternate route to, rather than this endpoint's own identity. + """ + return self._tls_session_cache_key_override is not None + def _default_tls_session_cache_key(self): return (self.address, self.port) @@ -848,6 +856,23 @@ class Connection(object): ssl_context = None last_error = None + # Whether this connection implementation can restore a cached TLS session + # before the handshake. True here because the accessors below speak the + # stdlib ssl API, which is what the asyncore and libev reactors use. A + # reactor that establishes TLS some other way sets this to False until it + # overrides those accessors -- asyncio hands the handshake to + # loop.create_connection(), which offers no point to restore a session at + # all. + supports_tls_session_resumption = True + + _ssl_session_cache = None + _tls_session_offered = None + _tls_handshake_began_at = None + + # RFC 8446 section 4.6.1: "Clients MUST NOT cache tickets for longer than + # 7 days, regardless of the ticket_lifetime". + _MAX_TLS_SESSION_LIFETIME = 7 * 24 * 60 * 60 + # The current number of operations that are in flight. More precisely, # the number of request IDs that are currently in use. # This includes orphaned requests. @@ -976,13 +1001,22 @@ def __init__(self, host='127.0.0.1', port=9042, authenticator=None, user_type_map=None, connect_timeout=None, allow_beta_protocol_version=False, no_compact=False, ssl_context=None, owning_pool=None, shard_id=None, total_shards=None, on_orphaned_stream_released=None, application_info: Optional[ApplicationInfoBase] = None, - session_id=None, driver_config_reporter: Optional[DriverConfigReporter] = None): + session_id=None, driver_config_reporter: Optional[DriverConfigReporter] = None, + ssl_session_cache=None): # TODO next major rename host to endpoint and remove port kwarg. self.endpoint = host if isinstance(host, EndPoint) else DefaultEndPoint(host, port) self.authenticator = authenticator self.ssl_options = ssl_options.copy() if ssl_options else {} self.ssl_context = ssl_context + # A TLS session can only be replayed onto the SSLContext it was + # established with -- the stdlib ssl module rejects anything else with + # "Session refers to a different SSLContext". Connections that derive + # their own context from ssl_options below therefore have nothing to + # gain from the cache, and would only fill it with sessions no one can + # use, so resumption is limited to a caller-supplied context. + if ssl_context is not None and self.supports_tls_session_resumption: + self._ssl_session_cache = ssl_session_cache self.sockopts = sockopts self.compression = compression self.cql_version = cql_version @@ -1122,17 +1156,16 @@ def _wrap_socket_from_context(self): # Extract a subset of names from self.ssl_options which apply to SSLContext.wrap_socket (or at least the parts # of it that don't involve building an SSLContext under the covers) - wrap_socket_opt_names = ['server_side', 'do_handshake_on_connect', 'suppress_ragged_eofs', 'server_hostname'] + wrap_socket_opt_names = ['server_side', 'do_handshake_on_connect', 'suppress_ragged_eofs'] opts = {k:self.ssl_options.get(k, None) for k in wrap_socket_opt_names if k in self.ssl_options} - # PYTHON-1186: set the server_hostname only if the SSLContext has - # check_hostname enabled and it is not already provided by the EndPoint ssl options - #opts['server_hostname'] = self.endpoint.address - if (self.ssl_context.check_hostname and 'server_hostname' not in opts): - server_hostname = self.endpoint.address + server_hostname = self._tls_server_hostname() + if server_hostname is not None: opts['server_hostname'] = server_hostname - return self.ssl_context.wrap_socket(self._socket, **opts) + ssl_sock = self.ssl_context.wrap_socket(self._socket, **opts) + self._restore_tls_session(ssl_sock) + return ssl_sock def _initiate_connection(self, sockaddr): if self.features.shard_id is not None: @@ -1146,6 +1179,238 @@ def _initiate_connection(self, sockaddr): self._socket.connect(sockaddr) + # TLS session resumption. Everything a reactor establishing TLS by other + # means than the stdlib ssl module has to reimplement is in the three + # accessors below -- _set_tls_session, _get_resumable_tls_session and + # _tls_negotiated_version -- and nothing else here touches the socket, so + # the policy around them is shared by every reactor that has them. + + def _tls_server_hostname(self): + """ + The name ``wrap_socket`` is given, which is the name the peer + certificate is verified against when the context checks hostnames. + + PYTHON-1186: the endpoint's ssl_options may provide it (an SNI proxy + needs it for routing); otherwise it is the endpoint address, and only + when the context actually checks hostnames. + """ + if 'server_hostname' in self.ssl_options: + return self.ssl_options['server_hostname'] + if getattr(self.ssl_context, 'check_hostname', False): + return self.endpoint.address + return None + + def _tls_session_cache_key(self): + # The SSLContext is part of the key because a session cannot be + # replayed onto a different one, and a cache may be shared by several + # clusters. It is held strongly: a cached session already keeps its + # context alive on its own -- CPython's SSLSession holds a reference to + # the context it was established with -- so holding it weakly here + # would buy nothing. + # The verified name is part of it because a resumed handshake carries no + # Certificate, so that name is never checked again: offering a session + # to a connection expecting a different name would silently skip + # hostname verification for it. Deriving the name from the same place + # _wrap_socket_from_context does is what keeps the two from drifting. + return (self.ssl_context, self.endpoint.tls_session_cache_key, + self._tls_server_hostname()) + + def _restore_tls_session(self, sock): + """ + Offer the session cached for this endpoint, if any, on *sock*, which + must not have begun its handshake yet. Not offering one only costs a + full handshake, so failures here are logged and ignored. + """ + # Set below only if a session is actually offered, so that this always + # describes the attempt in flight: _connect_socket may come back here + # for another address, and an earlier attempt's session is not this + # one's to retract. + self._tls_session_offered = None + # Taken for every attempt, offered or not: what is stored afterwards + # was issued during the handshake this is about to begin, and its + # lifetime runs from then rather than from when the CQL handshake + # finishes reading it. + self._tls_handshake_began_at = time.monotonic() + if self._ssl_session_cache is None: + return + + try: + session = self._ssl_session_cache.get(self._tls_session_cache_key()) + if session is not None: + self._set_tls_session(sock, session) + self._tls_session_offered = session + log.debug("Offering a cached TLS session to %s", self.endpoint) + except Exception as exc: + log.debug("Could not offer a cached TLS session to %s: %s", self.endpoint, exc) + + def _discard_tls_session(self): + """ + Drop the session offered on this connection, after a handshake it took + part in failed -- during _connect_socket, or, where ssl_options defer + the handshake past it, wherever the reactor meets the failure. + + A cached session should never be able to fail a handshake -- RFC 5077 + section 3.2 and RFC 8446 section 4.6.1 both have the server fall back to + a full one when it will not resume -- but nothing stores a fresh session + for a connection that never came up, so an entry that does provoke a + failure would otherwise be offered again by every later connection until + its lifetime ran out. + + Only the session this connection offered is dropped: another connection + may have stored a session the peer issued in its place, and removing + that would cost every later connection a full handshake for a session + that never failed anything. A store of the same session is not that, + and leaves the entry retractable. + + Nothing is dropped for an endpoint that borrowed its key from another. + An alternate listener resumes the node's session and stores back to it, + which is the point of sharing the key, but the entry is not its to + remove: it would cost the node's other pools and its control connection + a full handshake apiece, and a pool filling against that listener would + do it again on every retry. A session that genuinely cannot resume + fails on the endpoint that owns the key as well, and goes from there. + """ + offered, self._tls_session_offered = self._tls_session_offered, None + if offered is None or self._ssl_session_cache is None: + # Nothing was offered on this connection -- there was nothing + # cached, or setting it on the socket was refused -- so there is + # nothing of ours to retract. Going on would hand discard() no + # session to compare against, which tells it to drop whatever is + # there, including one a sibling connection stored in the meantime. + return + + if self.endpoint._tls_session_cache_key_is_borrowed: + log.debug("Leaving the TLS session %s offered where it is: the key " + "is another endpoint's", self.endpoint) + return + + try: + self._ssl_session_cache.discard(self._tls_session_cache_key(), offered) + log.debug("Dropped the cached TLS session offered to %s", self.endpoint) + except Exception as exc: + log.debug("Could not drop the cached TLS session of %s: %s", self.endpoint, exc) + + def _store_tls_session(self): + """ + Cache this connection's TLS session so that later connections to the + same peer can resume it. Called once the CQL handshake has completed, + which is late enough to have read a TLS 1.3 session ticket from a + server that sends one with the handshake, Scylla among them. + """ + # Reaching here is the TLS handshake having stood: the CQL one is + # complete, so there is nothing left to retract and the attribute goes + # back to meaning "offered, and not yet known to be good". + offered, self._tls_session_offered = self._tls_session_offered, None + if self._ssl_session_cache is None: + return + + try: + session = self._get_resumable_tls_session() + if session is None: + # Every connection samples at this same point in the CQL + # handshake, so reaching here is not something the next one + # retries: a peer that has not produced a ticket by now will + # not have for the next connection either, and nothing is ever + # cached for it. Scylla produces one well before this -- the + # TLS handshake plus the OPTIONS exchange -- so a peer that + # deferred its ticket past this point is what would call for a + # later hook than this one. + return + lifetime = self._tls_session_lifetime(session) + if lifetime is None: + return + self._ssl_session_cache.set(self._tls_session_cache_key(), + session, lifetime, offered=offered) + log.debug("Cached the TLS session of %s for resumption, for %ss", + self.endpoint, int(lifetime)) + except Exception as exc: + log.debug("Could not cache the TLS session of %s: %s", self.endpoint, exc) + + def _tls_session_lifetime(self, session): + """ + How much longer, in seconds, *session* may be offered, or ``None`` if + it must not be cached at all. + + A ticket's lifetime is the one the server announced; + ``SSLSession.timeout`` is the local context's default and says nothing + about what the peer will still accept, so it is only used where the + server announced nothing. RFC 8446 section 4.6.1 also caps a client at + seven days however long a lifetime the server asked for. + + A zero lifetime means opposite things in the two RFCs that define + tickets, so the negotiated version has to decide: RFC 8446 section 4.6.1 + (TLS 1.3) says discard the ticket immediately, while RFC 5077 section 3.3 + (TLS 1.2) reserves zero for "lifetime unspecified" and leaves retention + to local policy -- for which the local timeout is the only figure + available. + + What is left of that lifetime is what the entry gets. The peer issued + the ticket during the handshake, while this runs once the CQL handshake + has completed -- a startup exchange later, and an authentication one + after that -- so stamping the announced lifetime here would hand the + entry every second of that on top of what the peer allowed. The age is + measured from a monotonic mark taken as the TLS handshake began, not + from ``SSLSession.time``: that is a wall-clock stamp, and subtracting it + from ``time.time()`` would let a clock step landing in between decide + the answer -- far enough forward and nothing is cached at all, backward + and the age disappears. The deadline the cache keeps is monotonic too, + so nothing after this point can skew it either. + """ + if session.has_ticket: + lifetime = session.ticket_lifetime_hint + if not lifetime: + if self._tls_negotiated_version() == 'TLSv1.3': + return None + lifetime = session.timeout + elif self._tls_negotiated_version() == 'TLSv1.3': + # TLS 1.3 resumes only from a ticket, whose pre-shared key is the + # whole mechanism; the session id a TLS 1.3 handshake carries is + # legacy_session_id_echo (RFC 8446 section 4.1.3), which a server + # echoes for the middlebox compatibility mode of appendix D.4 and + # which resumes nothing. OpenSSL does not report such an id as the + # session's -- one appears only once a NewSessionTicket has been + # read, which is what defers the store until then -- so this is + # unreachable there; it is here so the rule follows from the + # protocol rather than from what one library chooses to expose. + return None + else: + lifetime = session.timeout + + lifetime = min(lifetime, self._MAX_TLS_SESSION_LIFETIME) + if self._tls_handshake_began_at is not None: + lifetime -= time.monotonic() - self._tls_handshake_began_at + return lifetime if lifetime > 0 else None + + def _set_tls_session(self, sock, session): + sock.session = session + + def _tls_negotiated_version(self): + """ + The name of the TLS version in force on this connection, as + ``SSLSocket.version`` reports it -- ``'TLSv1.3'`` and so on -- or + :const:`None` if there is no handshake to ask about. + + Only the retention rules need this: which RFC defines the tickets the + peer issues, and so what a lifetime of zero in one means, is decided by + the version, and no property of the session itself distinguishes them. + """ + return self._socket.version() + + def _get_resumable_tls_session(self): + session = getattr(self._socket, 'session', None) + if session is None: + return None + # There has to be something to offer on the next connection: a ticket + # (RFC 5077 for TLS 1.2, RFC 8446 for TLS 1.3) or a session id. A TLS + # 1.3 server sends its NewSessionTicket after the handshake as a + # separate message, and until that has been read the session carries + # neither, which is what keeps an empty one from being stored. Whether + # an id alone is worth anything is not decided here: that depends on + # the negotiated version, which _tls_session_lifetime reads. + if not (session.has_ticket or session.id): + return None + return session + # PYTHON-1331 # # Allow implementations specific to an event loop to add additional behaviours @@ -1187,12 +1452,22 @@ def _connect_socket(self): # run that here. if self._check_hostname: self._validate_hostname() + # The handshake stood, so there is nothing left to retract -- + # but what was offered is kept, because the store still reads + # it to tell a session the peer reissued from the one this + # connection offered and got back unchanged. sockerr = None break except socket.error as err: if self._socket: self._socket.close() self._socket = None + # Only for a TLS failure: a connection refused or reset says + # nothing about the session, and dropping it would cost a later + # connection a full handshake for no reason. Whether anything + # was offered to retract is _discard_tls_session's own business. + if isinstance(err, ssl.SSLError): + self._discard_tls_session() sockerr = err if sockerr: @@ -1231,6 +1506,15 @@ def defunct(self, exc): log.debug("Defuncting connection (%s) to %s: %s", id(self), self.endpoint, exc) + if isinstance(exc, ssl.SSLError): + # ssl_options may carry do_handshake_on_connect=False, which leaves + # the handshake to the first read or write and so to the reactor, + # where a failure arrives here rather than at _connect_socket. + # Nothing is retracted for a connection that got as far as the CQL + # handshake, which cleared what it had offered; only a TLS error + # counts, for the same reason it does there. + self._discard_tls_session() + self.last_error = exc self.close() self.error_all_cp_sessions(exc) @@ -1733,6 +2017,7 @@ def _handle_startup_response(self, startup_response, did_authenticate=False): if ProtocolVersion.has_checksumming_support(self.protocol_version): self._enable_checksumming() + self._store_tls_session() self.connected_event.set() elif isinstance(startup_response, AuthenticateMessage): log.debug("Got AuthenticateMessage on new connection (%s) from %s: %s", @@ -1789,6 +2074,7 @@ def _handle_auth_response(self, auth_response): self.authenticator.on_authentication_success(auth_response.token) if self._compressor: self.compressor = self._compressor + self._store_tls_session() self.connected_event.set() elif isinstance(auth_response, AuthChallengeMessage): response = self.authenticator.evaluate_challenge(auth_response.challenge) diff --git a/cassandra/io/asyncioreactor.py b/cassandra/io/asyncioreactor.py index 92ab972e7d..20fe79b851 100644 --- a/cassandra/io/asyncioreactor.py +++ b/cassandra/io/asyncioreactor.py @@ -118,8 +118,16 @@ class AsyncioConnection(Connection): Supports SSL connections via asyncio's native TLS transport, which avoids the incompatibility between ``ssl.SSLSocket`` and asyncio's low-level socket methods (``sock_sendall``, ``sock_recv``). + + TLS session resumption (:attr:`.Cluster.ssl_session_cache`) is not + available on this reactor: the handshake happens inside + ``loop.create_connection(..., ssl=...)``, which offers no point at which + a cached session could be restored. """ + # See the note on TLS session resumption above. + supports_tls_session_resumption = False + _loop = None _pid = os.getpid() diff --git a/cassandra/pool.py b/cassandra/pool.py index 14829ffa26..112a11a54d 100644 --- a/cassandra/pool.py +++ b/cassandra/pool.py @@ -719,6 +719,13 @@ def _get_shard_aware_endpoint(self): endpoint = copy.copy(self.host.endpoint) endpoint._port = self.host.sharding_info.shard_aware_port + if endpoint is not None: + # Another listener of this same node, with the same TLS + # credentials, so it offers and refreshes the session cached for + # the node rather than one of its own. + endpoint._tls_session_cache_key_override = \ + self.host.endpoint.tls_session_cache_key + return endpoint def _open_connection_to_missing_shard(self, shard_id): diff --git a/tests/unit/test_connection.py b/tests/unit/test_connection.py index fcea10dfaf..75f119f6c4 100644 --- a/tests/unit/test_connection.py +++ b/tests/unit/test_connection.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. import itertools +import copy +import ssl import unittest import uuid from io import BytesIO @@ -27,9 +29,11 @@ ConnectionException, ConnectionShutdown, DefaultEndPoint, ShardAwarePortGenerator, DRIVER_NAME, DRIVER_VERSION) from cassandra.driver_config import DRIVER_CONFIG_OPTION, SESSION_ID_OPTION +from cassandra.ssl_session_cache import SSLSessionCache from cassandra.marshal import uint8_pack, uint32_pack, int32_pack from cassandra.protocol import (write_stringmultimap, write_int, write_string, - read_stringmap, SupportedMessage, ProtocolHandler, + read_stringmap, AuthSuccessMessage, ReadyMessage, + SupportedMessage, ProtocolHandler, ResultMessage, RESULT_KIND_SET_KEYSPACE) from tests.unit.utils import StubReporter, ThrowingReporter @@ -906,6 +910,566 @@ def test_timer_collision(self): tm.service_timeouts() +class TlsSessionResumptionTest(unittest.TestCase): + """ + Connection-level wiring of :class:`~.SSLSessionCache`. The end-to-end + behaviour against a real TLS server lives in + ``tests/unit/io/test_tls_resumption.py``. + """ + + def make_connection(self, endpoint=None, **kwargs): + c = Connection(endpoint or DefaultEndPoint('1.2.3.4'), **kwargs) + c._socket = Mock() + return c + + def make_ssl_connection(self, cache=None, **kwargs): + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + return context, self.make_connection( + ssl_context=context, + ssl_session_cache=SSLSessionCache() if cache is None else cache, + **kwargs) + + def test_cache_is_used_with_a_supplied_ssl_context(self): + cache = SSLSessionCache() + _, connection = self.make_ssl_connection(cache) + + assert connection._ssl_session_cache is cache + + def test_cache_is_ignored_without_tls(self): + connection = self.make_connection(ssl_session_cache=SSLSessionCache()) + + assert connection._ssl_session_cache is None + + def test_cache_is_ignored_for_a_context_derived_from_ssl_options(self): + # Each such connection builds its own SSLContext, and a session cannot + # be replayed onto a different context, so there is nothing to cache. + connection = self.make_connection( + ssl_options={'ca_certs': None, 'check_hostname': False}, + ssl_session_cache=SSLSessionCache()) + + assert connection.ssl_context is not None + assert connection._ssl_session_cache is None + + def test_cache_is_ignored_when_the_reactor_cannot_resume(self): + class NoResumptionConnection(Connection): + supports_tls_session_resumption = False + + connection = NoResumptionConnection( + DefaultEndPoint('1.2.3.4'), + ssl_context=ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT), + ssl_session_cache=SSLSessionCache()) + + assert connection._ssl_session_cache is None + + def test_cache_key_separates_endpoints_and_contexts(self): + context, connection = self.make_ssl_connection() + other_endpoint_connection = self.make_connection( + ssl_context=context, ssl_session_cache=connection._ssl_session_cache) + other_endpoint_connection.endpoint = DefaultEndPoint('5.6.7.8') + _, other_context_connection = self.make_ssl_connection() + + assert connection._tls_session_cache_key() == \ + (context, ('1.2.3.4', 9042), '1.2.3.4') + assert connection._tls_session_cache_key() != \ + other_endpoint_connection._tls_session_cache_key() + assert connection._tls_session_cache_key() != \ + other_context_connection._tls_session_cache_key() + + def test_cache_key_separates_verified_hostnames(self): + # A resumed handshake sends no Certificate, so the name the peer was + # verified against is never re-checked. Two connections to one address + # that verify different names must not share a session. + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + one = self.make_connection(ssl_context=context, + ssl_options={'server_hostname': 'one.example'}, + ssl_session_cache=SSLSessionCache()) + other = self.make_connection(ssl_context=context, + ssl_options={'server_hostname': 'other.example'}, + ssl_session_cache=one._ssl_session_cache) + + assert one._tls_session_cache_key() != other._tls_session_cache_key() + + def test_cache_key_uses_the_name_wrap_socket_is_given(self): + # The key has to be derived from the same value _wrap_socket_from_context + # passes to wrap_socket, or the two can drift apart. + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.check_hostname = False + connection = self.make_connection(ssl_context=context, + ssl_options={'server_hostname': 'sni.example'}, + ssl_session_cache=SSLSessionCache()) + connection.ssl_context = Mock(check_hostname=False) + + connection._wrap_socket_from_context() + + _, kwargs = connection.ssl_context.wrap_socket.call_args + assert kwargs['server_hostname'] == 'sni.example' + assert connection._tls_session_cache_key()[2] == 'sni.example' + + def test_cache_key_falls_back_to_the_endpoint_address(self): + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + checking = self.make_connection(ssl_context=context, + ssl_session_cache=SSLSessionCache()) + context_without_checks = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context_without_checks.check_hostname = False + not_checking = self.make_connection(ssl_context=context_without_checks, + ssl_session_cache=SSLSessionCache()) + + assert context.check_hostname is True + assert checking._tls_session_cache_key()[2] == '1.2.3.4' + # Nothing is verified, so there is no name to pin the session to. + assert not_checking._tls_session_cache_key()[2] is None + + def test_cache_key_follows_an_endpoint_that_names_another_node(self): + # A shard-aware connection reaches the same node on a different port, + # and its endpoint carries that node's key + # (HostConnection._get_shard_aware_endpoint), so both share a session. + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + node = Connection(DefaultEndPoint('1.2.3.4', 9042), ssl_context=context, + ssl_session_cache=SSLSessionCache()) + alias = DefaultEndPoint('1.2.3.4', 19142) + alias._tls_session_cache_key_override = node.endpoint.tls_session_cache_key + shard_aware = Connection(alias, ssl_context=context, + ssl_session_cache=node._ssl_session_cache) + + assert shard_aware._tls_session_cache_key() == node._tls_session_cache_key() + + def test_cache_key_without_an_override_follows_the_endpoint(self): + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + node = Connection(DefaultEndPoint('1.2.3.4', 9042), ssl_context=context, + ssl_session_cache=SSLSessionCache()) + other_port = Connection(DefaultEndPoint('1.2.3.4', 19142), ssl_context=context, + ssl_session_cache=node._ssl_session_cache) + + assert other_port._tls_session_cache_key() != node._tls_session_cache_key() + + def test_restore_offers_the_cached_session(self): + cache = SSLSessionCache() + _, connection = self.make_ssl_connection(cache) + session = object() + cache.set(connection._tls_session_cache_key(), session) + sock = Mock() + + connection._restore_tls_session(sock) + + assert sock.session is session + # The session stays available for the next connection. + assert cache.get(connection._tls_session_cache_key()) is session + + def test_restore_is_a_no_op_without_a_cached_session(self): + _, connection = self.make_ssl_connection() + sock = Mock(spec=[]) + + connection._restore_tls_session(sock) + + assert not hasattr(sock, 'session') + + def test_restore_tolerates_a_rejected_session(self): + cache = SSLSessionCache() + _, connection = self.make_ssl_connection(cache) + cache.set(connection._tls_session_cache_key(), object()) + sock = Mock() + type(sock).session = property( + lambda self: None, + Mock(side_effect=ValueError("Session refers to a different SSLContext"))) + + # A rejected session must cost a full handshake, not the connection. + connection._restore_tls_session(sock) + + def test_discard_without_having_offered_anything_keeps_the_cache(self): + # Reached when there was nothing cached to offer, or when setting the + # session on the socket was refused. Passing no session to discard() + # would tell it to drop whatever is there, which may be one a sibling + # connection stored while this one was failing. + cache = SSLSessionCache() + _, connection = self.make_ssl_connection(cache) + cache.set(connection._tls_session_cache_key(), 'stored-by-a-sibling') + + connection._discard_tls_session() + + assert cache.get(connection._tls_session_cache_key()) == 'stored-by-a-sibling' + + def test_discard_after_a_refused_session_keeps_the_cache(self): + cache = SSLSessionCache() + _, connection = self.make_ssl_connection(cache) + cache.set(connection._tls_session_cache_key(), 'the-only-session') + connection._set_tls_session = Mock(side_effect=ValueError('refused')) + + connection._restore_tls_session(Mock()) + connection._discard_tls_session() + + assert cache.get(connection._tls_session_cache_key()) == 'the-only-session' + + def test_an_alternate_listener_does_not_retract_the_nodes_session(self): + # The shard-aware endpoint borrows the node's key so it can resume what + # the node established. A failure there is not grounds for dropping + # that entry: the node's other pools and its control connection resume + # from it, and a pool filling against this listener would drop it again + # on every retry. + cache = SSLSessionCache() + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + node = DefaultEndPoint('1.2.3.4', 9042) + alias = copy.copy(node) + alias._port = 19142 + alias._tls_session_cache_key_override = node.tls_session_cache_key + + owner = self.make_connection(endpoint=node, ssl_context=context, + ssl_session_cache=cache) + listener = self.make_connection(endpoint=alias, ssl_context=context, + ssl_session_cache=cache) + assert listener._tls_session_cache_key() == owner._tls_session_cache_key() + session = Mock(has_ticket=True, id=b'\x01' * 32) + cache.set(owner._tls_session_cache_key(), session, lifetime=3600) + + listener._tls_session_offered = session + listener._discard_tls_session() + + assert cache.get(owner._tls_session_cache_key()) is session + + # The endpoint that owns the key still retracts, so a session that + # really cannot resume is not kept for ever. + owner._tls_session_offered = session + owner._discard_tls_session() + + assert cache.get(owner._tls_session_cache_key()) is None + + def test_store_caches_a_session_carrying_a_ticket(self): + cache = SSLSessionCache() + _, connection = self.make_ssl_connection(cache) + session = Mock(has_ticket=True, id=b'', ticket_lifetime_hint=7200, + time=time.time(), timeout=7200) + connection._socket.session = session + + connection._store_tls_session() + + assert cache.get(connection._tls_session_cache_key()) is session + + def test_store_caches_a_session_carrying_only_an_id(self): + # Below TLS 1.3 a session id is offerable on its own, whether or not + # the server turns out to honour it. + cache = SSLSessionCache() + _, connection = self.make_ssl_connection(cache) + session = Mock(has_ticket=False, id=b'\x01' * 32, ticket_lifetime_hint=0, + time=time.time(), timeout=7200) + connection._socket.session = session + + connection._store_tls_session() + + assert cache.get(connection._tls_session_cache_key()) is session + + def test_store_skips_a_tls13_ticket_with_a_zero_lifetime(self): + # RFC 8446 4.6.1: a ticket announced with a lifetime of zero is to be + # discarded immediately. + cache = SSLSessionCache() + _, connection = self.make_ssl_connection(cache) + connection._socket.session = Mock(has_ticket=True, id=b'x' * 32, + ticket_lifetime_hint=0, + time=time.time(), timeout=7200) + connection._socket.version.return_value = 'TLSv1.3' + + connection._store_tls_session() + + assert cache.get(connection._tls_session_cache_key()) is None + + def test_store_keeps_a_tls12_ticket_with_an_unspecified_lifetime(self): + # RFC 5077 3.3 reserves a zero hint for "lifetime unspecified" and + # leaves retention to local policy, so the ticket is still usable and + # the local timeout is what there is to go on. + cache = SSLSessionCache() + _, connection = self.make_ssl_connection(cache) + session = Mock(has_ticket=True, id=b'x' * 32, ticket_lifetime_hint=0, + time=time.time(), timeout=300) + connection._socket.session = session + connection._socket.version.return_value = 'TLSv1.2' + + connection._store_tls_session() + + assert cache.get(connection._tls_session_cache_key()) is session + + def test_store_caps_the_lifetime_at_seven_days(self): + # RFC 8446 4.6.1: no ticket may be kept longer than 7 days, whatever + # lifetime the server asked for. + _, connection = self.make_ssl_connection() + connection._ssl_session_cache.set = Mock() + connection._socket.session = Mock(has_ticket=True, id=b'', + ticket_lifetime_hint=30 * 24 * 3600, + time=time.time(), timeout=7200) + + connection._store_tls_session() + + _, kwargs = connection._ssl_session_cache.set.call_args + lifetime = kwargs.get('lifetime', connection._ssl_session_cache.set.call_args[0][-1]) + assert 7 * 24 * 3600 - 5 < lifetime <= 7 * 24 * 3600 + + def test_store_uses_the_announced_ticket_lifetime_not_the_local_timeout(self): + # SSLSession.timeout is the local context default and says nothing about + # what the peer will still accept. + _, connection = self.make_ssl_connection() + connection._ssl_session_cache.set = Mock() + connection._socket.session = Mock(has_ticket=True, id=b'', + ticket_lifetime_hint=60, + time=time.time(), timeout=7200) + + connection._store_tls_session() + + lifetime = connection._ssl_session_cache.set.call_args[0][-1] + assert 55 < lifetime <= 60 + + def test_store_ignores_the_sessions_wall_clock_stamp(self): + # SSLSession.time is wall clock, so reducing the lifetime by + # time.time() - session.time would let a clock step landing between the + # handshake and the store decide the answer. The age is taken from a + # monotonic mark instead, which these connections never set, so the + # announced lifetime comes through whole whatever the stamp says. + for stamp in (time.time() - 10_000, time.time() + 10_000, 0): + _, connection = self.make_ssl_connection() + connection._ssl_session_cache.set = Mock() + connection._socket.session = Mock(has_ticket=True, id=b'', + ticket_lifetime_hint=100, + time=stamp, timeout=7200) + + connection._store_tls_session() + + lifetime = connection._ssl_session_cache.set.call_args[0][-1] + assert lifetime == 100, stamp + + def test_store_charges_the_ticket_for_the_time_since_the_handshake(self): + # The peer issued it during the handshake; the store happens a startup + # exchange and perhaps an authentication later, and that time is the + # peer's to spend, not the entry's. + _, connection = self.make_ssl_connection() + connection._ssl_session_cache.set = Mock() + connection._socket.session = Mock(has_ticket=True, id=b'\x01' * 32, + ticket_lifetime_hint=7200, + time=time.time(), timeout=7200) + connection._tls_handshake_began_at = time.monotonic() - 100 + + connection._store_tls_session() + + lifetime = connection._ssl_session_cache.set.call_args[0][-1] + assert 7099 < lifetime <= 7100 + + def test_store_skips_a_ticket_that_expired_before_it_could_be_cached(self): + # A short-lived ticket and a slow startup leave nothing worth keeping. + cache = SSLSessionCache() + _, connection = self.make_ssl_connection(cache) + connection._socket.session = Mock(has_ticket=True, id=b'\x01' * 32, + ticket_lifetime_hint=30, + time=time.time(), timeout=7200) + connection._tls_handshake_began_at = time.monotonic() - 60 + + connection._store_tls_session() + + assert cache.get(connection._tls_session_cache_key()) is None + + def test_store_skips_an_id_only_session_with_a_zero_timeout(self): + # The only way a lifetime can be nothing once the announced one is + # taken whole. + cache = SSLSessionCache() + _, connection = self.make_ssl_connection(cache) + connection._socket.session = Mock(has_ticket=False, id=b'x' * 32, + ticket_lifetime_hint=0, + time=time.time(), timeout=0) + + connection._store_tls_session() + + assert cache.get(connection._tls_session_cache_key()) is None + + def test_store_falls_back_to_the_timeout_for_an_id_only_session(self): + # A session that resumes by id carries no announced lifetime, so the + # local timeout is all there is to go on. + _, connection = self.make_ssl_connection() + connection._ssl_session_cache.set = Mock() + connection._socket.session = Mock(has_ticket=False, id=b'x' * 32, + ticket_lifetime_hint=0, + time=time.time(), timeout=300) + + connection._store_tls_session() + + lifetime = connection._ssl_session_cache.set.call_args[0][-1] + assert 295 < lifetime <= 300 + + def test_store_hands_the_cache_the_session_that_was_offered(self): + # What this connection offered is half of the decision the cache makes, + # so it has to reach it: without it, a connection whose entry was + # dropped meanwhile re-creates it with a fresh full lifetime, on a + # session the peer issued long enough ago to have expired. + cache = SSLSessionCache() + _, connection = self.make_ssl_connection(cache) + session = Mock(has_ticket=True, id=b'\x01' * 32, ticket_lifetime_hint=7200, + time=time.time(), timeout=7200) + connection._socket.session = session + # The state a resumed connection is in by the time it stores: it + # offered this session, and the entry has since gone. + connection._tls_session_offered = session + + connection._store_tls_session() + + assert cache.get(connection._tls_session_cache_key()) is None + assert len(cache) == 0 + + def test_store_skips_an_id_only_session_on_tls13(self): + # TLS 1.3 resumes only from a ticket, so an id on its own is nothing to + # offer however it got there. OpenSSL reports no id until a ticket has + # been read, so this state does not arise with it -- see + # test_the_session_is_only_stored_once_the_ticket_has_arrived in + # tests/unit/test_tls_resumption.py, which holds that against a real + # server -- and the rule is asserted here so it does not rest on that. + cache = SSLSessionCache() + _, connection = self.make_ssl_connection(cache) + connection._socket.session = Mock(has_ticket=False, id=b'\x01' * 32, + ticket_lifetime_hint=0, + time=time.time(), timeout=7200) + connection._socket.version.return_value = 'TLSv1.3' + + connection._store_tls_session() + + assert cache.get(connection._tls_session_cache_key()) is None + + def test_store_skips_a_session_with_nothing_to_offer(self): + # This is a TLS 1.3 session read before the server's NewSessionTicket + # has arrived: no ticket and no id, so it could never resume and must + # not displace a usable entry. + cache = SSLSessionCache() + _, connection = self.make_ssl_connection(cache) + cache.set(connection._tls_session_cache_key(), 'earlier-session') + connection._socket.session = Mock(has_ticket=False, id=b'') + + connection._store_tls_session() + + assert cache.get(connection._tls_session_cache_key()) == 'earlier-session' + + def test_store_tolerates_a_failure(self): + _, connection = self.make_ssl_connection() + connection._ssl_session_cache.set = Mock(side_effect=RuntimeError('boom')) + connection._socket.session = Mock(has_ticket=True, id=b'', + ticket_lifetime_hint=7200, + time=time.time(), timeout=7200) + + # _store_tls_session runs inside @defunct_on_error-wrapped handlers; + # a caching failure must never take the connection down. + connection._store_tls_session() + + # Asserted so the failure has to come from the cache: a session the + # accessors choke on would raise before ever reaching it, and the test + # would pass without covering what it names. + connection._ssl_session_cache.set.assert_called_once() + + def test_store_is_a_no_op_without_a_cache(self): + connection = self.make_connection() + + connection._store_tls_session() + + def test_the_accessors_are_the_whole_reactor_specific_surface(self): + # A reactor that establishes TLS by other means than an ssl.SSLSocket + # has no socket to read a session, a ticket or a version off, and + # reimplements the three accessors instead. Nothing else in the policy + # may reach for a socket, so this connection deliberately has none: + # anything that did would come back with no session cached and none + # offered. + session = Mock(has_ticket=True, id=b'\x01' * 32, + ticket_lifetime_hint=0, time=time.time(), timeout=7200) + + class OwnTransport(Connection): + offered = None + + def _set_tls_session(self, sock, restored): + self.offered = restored + + def _get_resumable_tls_session(self): + return session + + def _tls_negotiated_version(self): + # A zero ticket lifetime means "unspecified" here, not + # "discard", so the session is cached with the local timeout. + return 'TLSv1.2' + + cache = SSLSessionCache() + connection = OwnTransport( + DefaultEndPoint('1.2.3.4'), + ssl_context=ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT), + ssl_session_cache=cache) + + connection._store_tls_session() + assert cache.get(connection._tls_session_cache_key()) is session + + connection._restore_tls_session(sock=None) + assert connection.offered is session + + def test_a_tls_failure_in_the_reactor_drops_the_session_it_offered(self): + # ssl_options may carry do_handshake_on_connect=False, which leaves the + # handshake to the first read or write: the failure then arrives at + # defunct() rather than at _connect_socket, and would otherwise leave + # the session to be offered again by every later connection. + cache = SSLSessionCache() + _, connection = self.make_ssl_connection(cache) + session = Mock(has_ticket=True, id=b'\x01' * 32, ticket_lifetime_hint=7200, + time=time.time(), timeout=7200) + cache.set(connection._tls_session_cache_key(), session, lifetime=7200) + connection._tls_session_offered = session + connection.close = Mock() + + connection.defunct(ssl.SSLError('handshake failure')) + + assert cache.get(connection._tls_session_cache_key()) is None + + def test_a_connection_that_started_up_retracts_nothing_later(self): + # Storing is the point the TLS handshake is known to have stood, so + # what was offered stops being in flight there. A TLS error long + # afterwards says nothing about a session that already worked. + cache = SSLSessionCache() + _, connection = self.make_ssl_connection(cache) + session = Mock(has_ticket=True, id=b'\x01' * 32, ticket_lifetime_hint=7200, + time=time.time(), timeout=7200) + cache.set(connection._tls_session_cache_key(), session, lifetime=7200) + connection._socket.session = session + connection._tls_session_offered = session + connection._store_tls_session() + assert connection._tls_session_offered is None + connection.close = Mock() + + connection.defunct(ssl.SSLError('much later')) + + assert cache.get(connection._tls_session_cache_key()) is session + + def test_a_non_tls_failure_in_the_reactor_keeps_the_session(self): + # A reset or refused connection says nothing about the session, and + # dropping it would cost a later connection a full handshake. + cache = SSLSessionCache() + _, connection = self.make_ssl_connection(cache) + session = Mock(has_ticket=True, id=b'\x01' * 32, ticket_lifetime_hint=7200, + time=time.time(), timeout=7200) + cache.set(connection._tls_session_cache_key(), session, lifetime=7200) + connection._tls_session_offered = session + connection.close = Mock() + + connection.defunct(OSError('connection reset')) + + assert cache.get(connection._tls_session_cache_key()) is session + + def test_session_is_stored_once_the_connection_is_ready(self): + _, connection = self.make_ssl_connection() + connection._compressor = None + connection._store_tls_session = Mock() + connection.defunct = Mock() + + connection._handle_startup_response(ReadyMessage()) + + connection.defunct.assert_not_called() + connection._store_tls_session.assert_called_once_with() + + def test_session_is_stored_once_authentication_succeeds(self): + _, connection = self.make_ssl_connection() + connection._compressor = None + connection._store_tls_session = Mock() + connection.authenticator = Mock() + connection.defunct = Mock() + + connection._handle_auth_response(AuthSuccessMessage(token=None)) + + connection.defunct.assert_not_called() + connection._store_tls_session.assert_called_once_with() + + class DefaultEndPointTest(unittest.TestCase): def test_default_endpoint_properties(self): diff --git a/tests/unit/test_shard_aware.py b/tests/unit/test_shard_aware.py index 5c0b06c25d..fb906fe1ec 100644 --- a/tests/unit/test_shard_aware.py +++ b/tests/unit/test_shard_aware.py @@ -140,6 +140,27 @@ class OptionsHolder(object): assert shard_info.shard_id_from_token(Murmur3Token.from_key(b"e").value) == 4 assert shard_info.shard_id_from_token(Murmur3Token.from_key(b"100000").value) == 2 + def test_shard_aware_endpoint_carries_the_nodes_tls_identity(self): + """ + The alternate listener must resume from the session cached for the node, + not key on its own port. + """ + host = MagicMock() + host.endpoint = DefaultEndPoint("1.2.3.4") + session = MockSession(ssl_context=object()) + pool = HostConnection(host=host, host_distance=HostDistance.REMOTE, + session=session) + try: + for f in session.futures: + f.result() + shard_aware_endpoint = pool._get_shard_aware_endpoint() + assert shard_aware_endpoint.port == 19045 + assert (shard_aware_endpoint.tls_session_cache_key == + host.endpoint.tls_session_cache_key) + finally: + pool.shutdown() + session.cluster.executor.shutdown(wait=True) + def test_advanced_shard_aware_port(self): """ Test that on given a `shard_aware_port` on the OPTIONS message (ShardInfo class) From 3432358a099d92193eb6bc59cd7bf11a9af8824a Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Thu, 17 Sep 2026 16:09:25 +0200 Subject: [PATCH 177/200] Enable TLS session resumption from Cluster Create an SSLSessionCache per Cluster whenever TLS is configured through ssl_context, and hand it to every connection the cluster opens, so that resumption is on by default with no configuration. Pass ssl_session_cache=None to turn it off, or an instance of your own to size it or share it between clusters. No cache is created where resumption cannot work: the deprecated ssl_options-only path, whose per-connection SSLContexts a session cannot be replayed onto, and reactors that report they cannot restore a session before the handshake, which today means asyncio -- which is also what the default connection class resolves to on Python 3.12 and newer with no libev extension installed, asyncore having left the standard library there. connection_class is not required to derive from Connection, so one that does not report the capability at all is treated as lacking it rather than raising. Nothing is settled in advance. ssl_session_cache is a property, answering against whatever ssl_context and connection_class are in force when it is read and making a cache on first use where one is wanted -- once, under a lock of its own, however many threads ask at the same moment, since for a cluster given TLS after connect() the first to ask are whichever pools are opening connections. Both of those are public attributes, so a decision taken at construction would leave resumption off on a reactor that does support it, or hand the keyword to a connection class that does not take it -- and one retaken later needs the first to be remembered, which is how an explicit None came to be overwritten. Behind the property is what the caller set and nothing else: assigning None turns resumption off and leaves it off, and assigning a cache asks for one as much as passing it to the constructor does, whenever it is done. A cache the caller asked for that cannot be used reads back as None, and connect() says why. Asking for resumption and silently getting none is worse than not having it: a cache answered back here would be handed to every connection -- which a connection class that does not take the keyword cannot even accept -- and would sit reachable and empty for anyone reading it back, which is also what a server that issues no tickets looks like. Only what the caller set is worth interrupting anyone over, since a cache made for a cluster is only ever made where it can be used; turning TLS off afterwards is not something to complain about. Where nothing was asked for there is still something worth finding -- resumption is on by default wherever it works, so a cluster that configured TLS and will not get it is a surprise -- and that is a line at debug rather than a warning. A cluster with no TLS at all is told nothing, having nothing to resume. connect() is the one place that says any of it, so it is said once without anything having to record that it was; a cache assigned after that reads back as None just the same, without a second word about it. Saying it from the setter instead would speak too early, while the caller is still configuring -- assigning the cache before the context is an ordering that owes nobody a warning. Whose the cache is settles what becomes of it, and nothing has to track that either: the field behind the property holds what the caller set, and one made for the cluster is kept apart from it. One created here is reachable only through the attribute, so it and the sessions in it go when the cluster does and shutdown has nothing to do. One the caller supplied stays the caller's: shutdown leaves its entries alone, which is what lets clusters share sessions -- at the same time, or one after another, so that a cluster replacing an earlier one resumes rather than handshaking in full -- and keeps the driver from deleting rows in an object it does not own. Its entries hold the SSLContext their session was established with, bounded by the cache's max_size, and clear() is there for a caller who wants them gone sooner. Refs DRIVER-165 --- cassandra/cluster.py | 156 +++++++++++++- docs/api/cassandra/cluster.rst | 2 + tests/unit/test_cluster.py | 359 ++++++++++++++++++++++++++++++++- 3 files changed, 515 insertions(+), 2 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index a87b2d2082..76eb148be7 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -53,6 +53,7 @@ EndPoint, DefaultEndPoint, DefaultEndPointFactory, SniEndPointFactory, UnixSocketEndPoint, ConnectionBusy, locally_supported_compressions) +from cassandra.ssl_session_cache import SSLSessionCache from cassandra.cqltypes import UserType import cassandra.cqltypes as types from cassandra.encoder import Encoder @@ -867,6 +868,8 @@ def default_retry_policy(self, policy): .. versionadded:: 3.17.0 """ + # ssl_session_cache is a property, defined with the rest of the TLS + # session resumption code below. sockopts = None """ An optional list of tuples which will be used as arguments to @@ -1167,6 +1170,12 @@ def token_metadata_enabled(self, enabled): _prepared_statements = None _prepared_statement_lock = None _idle_heartbeat = None + _ssl_session_cache = _NOT_SET + _ssl_session_cache_created = None + # Held only while the cache above is constructed, and nothing else is held + # underneath it: making one is not something that can take part in an + # ordering with the pool locks, which self._lock would. + _ssl_session_cache_lock = None _protocol_version_explicit = False _discount_down_events = True @@ -1222,7 +1231,8 @@ def __init__(self, application_info:Optional[ApplicationInfoBase]=None, client_routes_config:Optional[ClientRoutesConfig]=None, allow_control_connection_query_fallback:Optional[ControlConnectionQueryFallback]=ControlConnectionQueryFallback.Disabled, - driver_config_reporting_enabled=True + driver_config_reporting_enabled=True, + ssl_session_cache=_NOT_SET ): """ ``executor_threads`` defines the number of threads in a pool for handling asynchronous tasks such as @@ -1469,6 +1479,10 @@ def __init__(self, self.ssl_options = ssl_options self.ssl_context = ssl_context + + self._ssl_session_cache_lock = Lock() + self._ssl_session_cache = ssl_session_cache + # Materialized once: these are applied to every socket the cluster opens # and are read again to build the configuration report, so a one-shot # iterable would leave whichever consumer ran second with nothing at all. @@ -1681,6 +1695,133 @@ def add_execution_profile(self, name, profile, pool_wait_timeout=5): raise OperationTimedOut("Failed to create all new connection pools in the %ss timeout." % pool_wait_timeout, timeout=pool_wait_timeout) + @property + def ssl_session_cache(self): + """ + A :class:`~cassandra.ssl_session_cache.SSLSessionCache` shared by every + connection this cluster opens, letting them resume TLS sessions instead of + performing a full handshake each time. This matters most for the group of + per-shard connections opened to a node at once, and for reconnections. + + One is created on first use when :attr:`~Cluster.ssl_context` is set. + Nothing is settled before then: this answers against whatever + :attr:`~Cluster.ssl_context` and :attr:`~Cluster.connection_class` are in + force when it is read, so configuring TLS at any point still gets a cache, + and swapping in a connection class that cannot resume turns resumption off + rather than handing that class a keyword it does not take. + + A cache created here is reachable only through this attribute, so it and + the sessions in it go when the cluster does. A cache passed in stays the + caller's: :meth:`~.Cluster.shutdown` leaves its entries alone, so several + clusters -- at the same time or one after another -- can share the + sessions in it. Its entries hold the ``SSLContext`` they were established + with, bounded by the cache's + :attr:`~cassandra.ssl_session_cache.SSLSessionCache.max_size`; call + :meth:`~cassandra.ssl_session_cache.SSLSessionCache.clear` to release + them. + + Assigning it is honoured whenever, and a cache that cannot be used reads + back as :const:`None` rather than being left to fill with nothing. Why + it cannot be used is said once, by :meth:`~.Cluster.connect`, which is + the only place that says it: a cache assigned after that reads back as + :const:`None` just the same, without a second word about it. + + Pass ``ssl_session_cache=None`` to :class:`.Cluster` to turn resumption + off, or pass your own instance to size it or to share it between + clusters:: + + from cassandra.ssl_session_cache import SSLSessionCache + + cluster = Cluster(ssl_context=ssl_context, + ssl_session_cache=SSLSessionCache(max_size=64)) + + Resumption is available when TLS is configured through + :attr:`~Cluster.ssl_context` and the reactor establishes TLS with the + standard library's ``ssl`` module: the ``libev`` reactor, and ``asyncore`` + on the Python versions that still ship it, which is up to 3.11. Which of + them is the default depends on what can be imported -- libev first, then + asyncore, then asyncio -- so on Python 3.12 and newer without the libev + extension the default is the ``asyncio`` reactor, and resumption is off. + + It is not available with the deprecated :attr:`~Cluster.ssl_options`-only + configuration, because each connection builds its own ``SSLContext`` and a + session cannot be replayed onto a different one; nor on the ``asyncio`` + reactor, which performs the handshake inside + ``loop.create_connection()``, leaving no point at which to restore a + session. In those cases no cache is created and connections handshake in + full. +""" + if not self._tls_session_resumption_available(): + return None + if self._ssl_session_cache is not _NOT_SET: + return self._ssl_session_cache + if self._ssl_session_cache_created is None: + # Reached from whichever threads are opening connections, which for + # a cluster configured with TLS after connect() is several pools at + # once. Two of them both finding nothing here would each make a + # cache and the later one would win, leaving the connections of the + # other to fill an object nothing can reach and never resume from. + with self._ssl_session_cache_lock: + if self._ssl_session_cache_created is None: + self._ssl_session_cache_created = SSLSessionCache() + return self._ssl_session_cache_created + + @ssl_session_cache.setter + def ssl_session_cache(self, cache): + self._ssl_session_cache = cache + + def _tls_session_resumption_available(self): + """ + Whether a session could be resumed at all: it has to be replayable onto + the same ``SSLContext``, and the reactor has to give the driver a chance + to offer it before the handshake. connection_class is not required to + derive from Connection, so one that does not report the capability is + treated as lacking it rather than raising. + """ + return (self.ssl_context is not None and + getattr(self.connection_class, + 'supports_tls_session_resumption', False)) + + def _report_tls_session_resumption(self): + """ + Say, once, why a cluster configured for TLS will not resume sessions. + + A cache the caller asked for and cannot have is a warning: asking for + resumption and silently getting none is worse than not having it, since + the attribute reads as None and nothing is ever cached, which is also + what a server that issues no tickets looks like. + + Where nothing was asked for it is only a note. Resumption is on by + default wherever it works, so a cluster that configured TLS and will + not get it has something worth finding -- a Python 3.12 or newer + without the libev extension resolves to the asyncio reactor, which + cannot restore a session -- but nobody asked, so this is not news to + interrupt anyone with. A cluster with no TLS at all is told nothing: + there is nothing there to resume. + """ + if self._tls_session_resumption_available(): + return + if self.ssl_context is None and self.ssl_options is None: + return + + if self.ssl_context is None: + reason = ('no ssl_context is configured, and a session cannot be ' + 'replayed onto the fresh context each connection builds ' + 'from ssl_options') + else: + reason = ('%s cannot restore a session before the handshake' % + getattr(self.connection_class, '__name__', + self.connection_class)) + + if (self._ssl_session_cache is _NOT_SET + or self._ssl_session_cache is None): + log.debug('TLS session resumption is unavailable here, so every ' + 'connection will perform a full handshake: %s.', reason) + else: + log.warning('ssl_session_cache is set but TLS session resumption ' + 'is unavailable here, so no sessions will be cached: ' + '%s.', reason) + def connection_factory(self, endpoint, host_conn = None, *args, **kwargs): """ Called to create a new connection with proper configuration. @@ -1702,6 +1843,13 @@ def _make_connection_kwargs(self, endpoint, kwargs_dict): kwargs_dict.setdefault('sockopts', self.sockopts) kwargs_dict.setdefault('ssl_options', self.ssl_options) kwargs_dict.setdefault('ssl_context', self.ssl_context) + ssl_session_cache = self.ssl_session_cache + if ssl_session_cache is not None: + # Set only where resumption is possible, so this is also the test + # for that: a connection class that does not accept the keyword + # should not have to grow one for a cluster that will never cache a + # session. + kwargs_dict.setdefault('ssl_session_cache', ssl_session_cache) kwargs_dict.setdefault('cql_version', self.cql_version) kwargs_dict.setdefault('protocol_version', self.protocol_version) kwargs_dict.setdefault('user_type_map', self._user_types) @@ -1761,6 +1909,7 @@ def connect(self, keyspace=None, wait_for_all_pools=False): self.contact_points, self.protocol_version) self.connection_class.initialize_reactor() _register_cluster_shutdown(self) + self._report_tls_session_resumption() try: self.control_connection.connect() @@ -1850,6 +1999,11 @@ def shutdown(self): if self.metrics_enabled and self.metrics: self.metrics.shutdown() + # Nothing to do here for ssl_session_cache: a cache created for this + # cluster is reachable only through it and goes when it does, and a + # cache the caller supplied is the caller's to empty -- deleting rows + # in it here would defeat sharing one so that sessions outlive a + # cluster. See the attribute's documentation. _discard_cluster_shutdown(self) def __enter__(self): diff --git a/docs/api/cassandra/cluster.rst b/docs/api/cassandra/cluster.rst index cf9cc59fc4..f0149244a6 100644 --- a/docs/api/cassandra/cluster.rst +++ b/docs/api/cassandra/cluster.rst @@ -43,6 +43,8 @@ Clusters and Sessions .. autoattribute:: ssl_options + .. autoattribute:: ssl_session_cache + .. autoattribute:: sockopts .. autoattribute:: max_schema_agreement_wait diff --git a/tests/unit/test_cluster.py b/tests/unit/test_cluster.py index 74ed346c68..755a3f4888 100644 --- a/tests/unit/test_cluster.py +++ b/tests/unit/test_cluster.py @@ -14,8 +14,13 @@ import unittest from concurrent.futures import Future +import gc import logging import socket +import ssl +import threading +import time +import weakref from types import SimpleNamespace from unittest.mock import patch, Mock @@ -25,7 +30,9 @@ InvalidRequest, Unauthorized, AuthenticationFailed, OperationTimedOut, UnsupportedOperation, RequestValidationException, ConfigurationException, ProtocolVersion from cassandra.cluster import _Scheduler, Session, Cluster, ResultSet, SchemaAgreementScope, ControlConnectionQueryFallback, default_lbp_factory, \ ExecutionProfile, _ConfigMode, EXEC_PROFILE_DEFAULT -from cassandra.connection import ConnectionBusy, ConnectionException +from cassandra.connection import (Connection, ConnectionBusy, ConnectionException, + DefaultEndPoint) +from cassandra.ssl_session_cache import SSLSessionCache from cassandra.driver_config import DriverConfigReporter from cassandra.pool import Host from cassandra.policies import HostDistance, RetryPolicy, RoundRobinPolicy, DowngradingConsistencyRetryPolicy, SimpleConvictionPolicy @@ -1167,3 +1174,353 @@ def test_no_warning_adding_lbp_ep_to_cluster_with_contact_points(self): ) patched_logger.warning.assert_not_called() + + +class _ResumableConnection(Connection): + supports_tls_session_resumption = True + + +class _NonResumableConnection(Connection): + supports_tls_session_resumption = False + + +class ClusterSSLSessionCacheTest(unittest.TestCase): + + def make_cluster(self, connection_class=_ResumableConnection, **kwargs): + cluster = Cluster(connection_class=connection_class, **kwargs) + # Every Cluster starts a _Scheduler thread in __init__, so one that is + # constructed and dropped leaks it for the rest of the session. + self.addCleanup(cluster.shutdown) + return cluster + + def test_cache_is_created_for_an_ssl_context(self): + cluster = self.make_cluster(ssl_context=ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)) + + assert isinstance(cluster.ssl_session_cache, SSLSessionCache) + # Made once and kept: a second one would take the sessions of whatever + # was opened against the first. + assert cluster.ssl_session_cache is cluster.ssl_session_cache + + def test_no_cache_without_tls(self): + assert self.make_cluster().ssl_session_cache is None + + def test_no_cache_for_ssl_options_only(self): + # Each connection builds its own SSLContext from ssl_options, and a + # session cannot be replayed onto a different context. + with patch('cassandra.cluster.warn'): + cluster = self.make_cluster(ssl_options={'ca_certs': '/dev/null'}) + + assert cluster.ssl_session_cache is None + + def test_no_cache_for_a_reactor_that_cannot_resume(self): + cluster = self.make_cluster(connection_class=_NonResumableConnection, + ssl_context=ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)) + + assert cluster.ssl_session_cache is None + + def test_no_cache_for_a_connection_class_that_reports_nothing(self): + # connection_class is not required to derive from Connection (see + # test_set_connection_class), so a class without the capability + # attribute must be treated as unable to resume, not blow up. + cluster = self.make_cluster(connection_class='not a connection class', + ssl_context=ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)) + + assert cluster.ssl_session_cache is None + + def test_a_supplied_cache_is_used(self): + cache = SSLSessionCache(max_size=7) + cluster = self.make_cluster(ssl_context=ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT), + ssl_session_cache=cache) + + assert cluster.ssl_session_cache is cache + + def test_warns_when_a_supplied_cache_cannot_be_used(self): + # Asking for resumption and silently getting none is worse than not + # having it: the attribute reads as None and nothing is ever cached. + with patch('cassandra.cluster.warn'): + cluster = self.make_cluster(ssl_options={'ca_certs': '/dev/null'}, + ssl_session_cache=SSLSessionCache()) + + with patch('cassandra.cluster.log') as logger: + cluster._report_tls_session_resumption() + + logger.warning.assert_called_once() + assert 'ssl_session_cache' in logger.warning.call_args[0][0] + assert 'ssl_context' in logger.warning.call_args[0][1] + + def test_warns_when_the_reactor_cannot_resume(self): + cluster = self.make_cluster( + connection_class=_NonResumableConnection, + ssl_context=ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT), + ssl_session_cache=SSLSessionCache()) + + with patch('cassandra.cluster.log') as logger: + cluster._report_tls_session_resumption() + + logger.warning.assert_called_once() + assert '_NonResumableConnection' in logger.warning.call_args[0][1] + + def test_warns_about_a_cache_assigned_to_a_cluster_that_cannot_use_it(self): + # Assigning is as much asking for one as passing it in, and nothing + # about the answer depends on which was done when. + cluster = self.make_cluster(connection_class=_NonResumableConnection, + ssl_context=ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)) + cluster.ssl_session_cache = SSLSessionCache() + + with patch('cassandra.cluster.log') as logger: + cluster._report_tls_session_resumption() + + logger.warning.assert_called_once() + assert '_NonResumableConnection' in logger.warning.call_args[0][1] + assert cluster.ssl_session_cache is None + + def test_does_not_warn_about_a_cache_the_caller_never_asked_for(self): + # The cache here was made for this cluster, so turning TLS off is not + # something to complain about: nobody asked for resumption. + cluster = self.make_cluster(ssl_context=ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)) + assert cluster.ssl_session_cache is not None + + cluster.ssl_context = None + with patch('cassandra.cluster.log') as logger: + cluster._report_tls_session_resumption() + + logger.warning.assert_not_called() + assert cluster.ssl_session_cache is None + + def test_an_unusable_cache_is_not_kept_or_passed_on(self): + # Warning and then handing the cache to every connection anyway is the + # worst of both: a connection class that does not take the keyword + # cannot even be constructed. + with patch('cassandra.cluster.log'): + cluster = self.make_cluster( + connection_class=_NonResumableConnection, + ssl_context=ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT), + ssl_session_cache=SSLSessionCache()) + + assert cluster.ssl_session_cache is None + kwargs = cluster._make_connection_kwargs(DefaultEndPoint('127.0.0.1'), {}) + assert 'ssl_session_cache' not in kwargs + + def test_does_not_warn_where_resumption_works_or_was_declined(self): + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + clusters = [ + self.make_cluster(ssl_context=context, + ssl_session_cache=SSLSessionCache()), + self.make_cluster(ssl_context=context, ssl_session_cache=None), + self.make_cluster(), + ] + + with patch('cassandra.cluster.log') as logger: + for cluster in clusters: + cluster._report_tls_session_resumption() + + logger.warning.assert_not_called() + + def test_notes_at_debug_where_nobody_asked_for_a_cache(self): + # Resumption is on by default wherever it works, so a cluster that + # configured TLS and will not get it has something worth finding. + # Nobody asked for it, though, so it is a note rather than a warning. + cluster = self.make_cluster(connection_class=_NonResumableConnection, + ssl_context=ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)) + + with patch('cassandra.cluster.log') as logger: + cluster._report_tls_session_resumption() + + logger.warning.assert_not_called() + said = [call[0][0] for call in logger.debug.call_args_list] + assert any('_NonResumableConnection' in call[0][1] + for call in logger.debug.call_args_list), said + + def test_says_nothing_to_a_cluster_with_no_tls(self): + # Nothing there to resume, so nothing to report at any level. + cluster = self.make_cluster(connection_class=_NonResumableConnection) + + with patch('cassandra.cluster.log') as logger: + cluster._report_tls_session_resumption() + + logger.warning.assert_not_called() + logger.debug.assert_not_called() + + def test_connect_says_why_a_cache_it_cannot_use_will_stay_empty(self): + # connect() is the only caller, so without this nothing holds the + # wiring: the method could be left in place and never reached. + cluster = self.make_cluster( + connection_class=_NonResumableConnection, + ssl_context=ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT), + ssl_session_cache=SSLSessionCache()) + cluster.idle_heartbeat_interval = 0 + + with patch('cassandra.cluster.log') as logger: + with patch.object(cluster.control_connection, 'connect'), \ + patch.object(cluster, '_populate_hosts'), \ + patch.object(cluster.profile_manager, 'check_supported'), \ + patch.object(cluster, '_new_session'), \ + patch.object(cluster, '_set_default_dbaas_consistency'): + cluster.connect() + + said = [call[0][0] for call in logger.warning.call_args_list] + assert any('ssl_session_cache' in message for message in said), said + + def test_resumption_can_be_turned_off(self): + cluster = self.make_cluster(ssl_context=ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT), + ssl_session_cache=None) + + assert cluster.ssl_session_cache is None + + def test_assigning_none_turns_resumption_off(self): + # The documented opt-out, taken after construction rather than at it: + # nothing may put a cache back afterwards, or the attribute would not + # mean what it says. + cluster = self.make_cluster(ssl_context=ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)) + assert cluster.ssl_session_cache is not None + + cluster.ssl_session_cache = None + + assert cluster.ssl_session_cache is None + kwargs = cluster._make_connection_kwargs(DefaultEndPoint('127.0.0.1'), {}) + assert 'ssl_session_cache' not in kwargs + + def test_assigning_none_turns_off_a_cache_given_to_the_constructor(self): + # The same, over the top of a cache the caller passed in: the last + # thing they said about it is the one that counts. + cluster = self.make_cluster(ssl_context=ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT), + ssl_session_cache=SSLSessionCache()) + assert cluster.ssl_session_cache is not None + + cluster.ssl_session_cache = None + + assert cluster.ssl_session_cache is None + + def test_shutdown_leaves_a_supplied_cache_alone(self): + # The cache belongs to whoever passed it in, and the point of passing + # one in is that its sessions outlive a cluster: a cluster replacing + # this one resumes rather than handshaking in full. A cache created + # for a cluster needs no shutdown hook either -- it is reachable only + # through the cluster, so it goes when the cluster does. + cache = SSLSessionCache() + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + cluster = self.make_cluster(ssl_context=context, ssl_session_cache=cache) + session = object() + cache.set((context, ('10.0.0.1', 9042), None), session) + + cluster.shutdown() + + assert cache.get((context, ('10.0.0.1', 9042), None)) is session + + def test_threads_reading_it_at_once_all_get_the_same_cache(self): + # A cluster given TLS after connect() first reaches the creating branch + # from whichever pools are opening connections, several at a time. Two + # of them each making one would leave the connections of the loser + # filling a cache nothing else can reach. + cluster = self.make_cluster(ssl_context=ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)) + started = threading.Barrier(8) + seen = [] + + # Widen the window the check-then-act leaves: without the lock the + # sleep guarantees every thread finds nothing there. + real = SSLSessionCache + + def slow_cache(*args, **kwargs): + time.sleep(0.05) + return real(*args, **kwargs) + + def read(): + started.wait(timeout=10) + seen.append(cluster.ssl_session_cache) + + with patch('cassandra.cluster.SSLSessionCache', slow_cache): + threads = [threading.Thread(target=read) for _ in range(8)] + for t in threads: + t.start() + for t in threads: + t.join(timeout=10) + + assert len(seen) == 8 + assert all(cache is seen[0] for cache in seen), seen + assert cluster.ssl_session_cache is seen[0] + + def test_a_cache_created_here_goes_when_the_cluster_does(self): + # Built without make_cluster, whose addCleanup would hold the cluster. + cluster = Cluster(connection_class=_ResumableConnection, + ssl_context=ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)) + cache = weakref.ref(cluster.ssl_session_cache) + cluster.shutdown() + + del cluster + gc.collect() + + assert cache() is None + + def test_the_answer_follows_a_connection_class_that_can_resume(self): + # Both inputs are public attributes, so a decision kept from the + # constructor would leave resumption off on a reactor that supports it. + cluster = self.make_cluster(connection_class=_NonResumableConnection, + ssl_context=ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)) + assert cluster.ssl_session_cache is None + + cluster.connection_class = _ResumableConnection + + assert isinstance(cluster.ssl_session_cache, SSLSessionCache) + + def test_the_answer_follows_a_connection_class_that_cannot(self): + # Otherwise the keyword goes to a class that may not take it. + cluster = self.make_cluster(ssl_context=ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)) + assert cluster.ssl_session_cache is not None + + cluster.connection_class = _NonResumableConnection + + assert cluster.ssl_session_cache is None + kwargs = cluster._make_connection_kwargs(DefaultEndPoint('127.0.0.1'), {}) + assert 'ssl_session_cache' not in kwargs + + def test_the_answer_follows_a_context_set_after_construction(self): + cluster = self.make_cluster() + assert cluster.ssl_session_cache is None + + cluster.ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + + assert isinstance(cluster.ssl_session_cache, SSLSessionCache) + + def test_a_declined_cache_stays_declined(self): + # Every read settles the question again, so the answer has to keep + # being no: nothing may put a cache here behind the caller's back on + # some later look. + cluster = self.make_cluster(ssl_context=ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT), + ssl_session_cache=None) + + assert [cluster.ssl_session_cache for _ in range(3)] == [None] * 3 + + def test_a_supplied_cache_is_returned_every_time(self): + # Connections are given whatever this answers, one read per connection, + # so an answer that varied would have them filling different caches and + # resuming from none of them. + cache = SSLSessionCache() + cluster = self.make_cluster(ssl_context=ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT), + ssl_session_cache=cache) + + assert all(cluster.ssl_session_cache is cache for _ in range(3)) + + def test_cache_is_passed_to_connections(self): + cluster = self.make_cluster(ssl_context=ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)) + + kwargs = cluster._make_connection_kwargs(DefaultEndPoint('127.0.0.1'), {}) + + assert kwargs['ssl_session_cache'] is cluster.ssl_session_cache + + def test_no_cache_keyword_when_resumption_is_inactive(self): + # A connection class that does not accept the keyword should not be + # handed one for a cluster that will never cache a session. + cluster = self.make_cluster() + + kwargs = cluster._make_connection_kwargs(DefaultEndPoint('127.0.0.1'), {}) + + assert 'ssl_session_cache' not in kwargs + + def test_an_explicitly_passed_cache_still_reaches_the_connection(self): + cache = SSLSessionCache() + cluster = self.make_cluster() + + kwargs = cluster._make_connection_kwargs(DefaultEndPoint('127.0.0.1'), + {'ssl_session_cache': cache}) + + assert kwargs['ssl_session_cache'] is cache From 7f03d37b764f35c0be3606ce42ef4eaf7e511a3f Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Tue, 22 Sep 2026 12:08:41 +0200 Subject: [PATCH 178/200] Test TLS session resumption against a real TLS server Stand up a TLS server on loopback and connect to it with the driver's own socket setup, so the restore-before-handshake and store-after-startup paths run for real and the result is read back the way OpenSSL reports it, through SSLSocket.session_reused. Covers TLS 1.2 and TLS 1.3, the latter skipped where the local OpenSSL does not offer it -- skipping the subclass rather than the base, since a skipped base would take its subclasses with it. The certificates come from tests/tls_certificates.py rather than from here, so that the integration suite generates its own the same way -- the addresses to name are the caller's argument, which is what that suite needs and this one takes the loopback default for. cryptography is optional, so that module reports whether it found it and both suites skip on that. One waits for the monotonic clock to move before opening its second connection. What a store works out as the deadline is the mark taken when the handshake began plus the lifetime announced -- the two readings of the clock cancel -- so two handshakes it cannot tell apart are given the same deadline, which is right but leaves a comparison of the two nothing to see. Windows resolves that clock to about ten milliseconds, and two loopback handshakes fit inside it. Two of these pin down behaviour that is easy to regress: that four connections opened at once all resume from the single cached session -- the per-shard burst DRIVER-165 is about -- and that on TLS 1.3 nothing is cached until the server's NewSessionTicket has actually been read off the socket. Refs DRIVER-165 --- tests/tls_certificates.py | 78 ++++++ tests/unit/test_connection.py | 2 +- tests/unit/test_tls_resumption.py | 434 ++++++++++++++++++++++++++++++ 3 files changed, 513 insertions(+), 1 deletion(-) create mode 100644 tests/tls_certificates.py create mode 100644 tests/unit/test_tls_resumption.py diff --git a/tests/tls_certificates.py b/tests/tls_certificates.py new file mode 100644 index 0000000000..b6362f8dff --- /dev/null +++ b/tests/tls_certificates.py @@ -0,0 +1,78 @@ +# Copyright 2026 ScyllaDB, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Certificates for tests that need a TLS server, generated rather than checked in +so that nothing expires in the repository. + +``cryptography`` is optional, so this imports it defensively and reports what it +found in :data:`HAVE_CRYPTOGRAPHY`; a suite that needs a certificate skips on +that rather than failing to import. +""" + +import datetime +import ipaddress +import os + +try: + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.x509.oid import NameOID + HAVE_CRYPTOGRAPHY = True +except ImportError: # pragma: no cover - depends on the environment + HAVE_CRYPTOGRAPHY = False + + +def write_self_signed_cert(directory, addresses=('127.0.0.1',)): + """ + Write a self-signed certificate naming every address in *addresses*, and + its key, into *directory*. Returns ``(cert_path, key_path)``. + + Every address a client will connect to has to be named: the client verifies + hostnames, so a certificate covering only the first of them would leave it + unable to reach the rest. Callers against a cluster pass every node's + address for that reason; one against a loopback server takes the default. + """ + if not HAVE_CRYPTOGRAPHY: + raise RuntimeError('cryptography is required to generate a certificate') + + addresses = list(addresses) + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, addresses[0])]) + now = datetime.datetime.now(datetime.timezone.utc) + certificate = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(minutes=5)) + .not_valid_after(now + datetime.timedelta(days=1)) + .add_extension( + x509.SubjectAlternativeName( + [x509.IPAddress(ipaddress.ip_address(address)) + for address in addresses]), + critical=False) + .sign(key, hashes.SHA256()) + ) + + cert_path = os.path.join(directory, 'server.crt') + key_path = os.path.join(directory, 'server.key') + with open(cert_path, 'wb') as f: + f.write(certificate.public_bytes(serialization.Encoding.PEM)) + with open(key_path, 'wb') as f: + f.write(key.private_bytes(serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, + serialization.NoEncryption())) + return cert_path, key_path diff --git a/tests/unit/test_connection.py b/tests/unit/test_connection.py index 75f119f6c4..b5074f7546 100644 --- a/tests/unit/test_connection.py +++ b/tests/unit/test_connection.py @@ -914,7 +914,7 @@ class TlsSessionResumptionTest(unittest.TestCase): """ Connection-level wiring of :class:`~.SSLSessionCache`. The end-to-end behaviour against a real TLS server lives in - ``tests/unit/io/test_tls_resumption.py``. + ``tests/unit/test_tls_resumption.py``. """ def make_connection(self, endpoint=None, **kwargs): diff --git a/tests/unit/test_tls_resumption.py b/tests/unit/test_tls_resumption.py new file mode 100644 index 0000000000..fa706517e5 --- /dev/null +++ b/tests/unit/test_tls_resumption.py @@ -0,0 +1,434 @@ +# Copyright 2026 ScyllaDB, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +TLS session resumption exercised against a real TLS server on loopback. + +These tests drive the actual code paths a connection uses -- restoring a +cached session onto the socket before the handshake, and storing the +negotiated session afterwards -- and check the outcome the way OpenSSL +reports it, through ``SSLSocket.session_reused``. No Cassandra or Scylla +server is involved: the peer speaks TLS and echoes bytes, which is all the +socket-level code under test needs. +""" + +import gc +import socket +import ssl +import tempfile +import threading +import time +import unittest +import weakref + +import pytest +from unittest.mock import Mock + +from cassandra.connection import Connection, DefaultEndPoint +from cassandra.ssl_session_cache import SSLSessionCache +from tests.tls_certificates import HAVE_CRYPTOGRAPHY, write_self_signed_cert + + + +def _wait_for_the_monotonic_clock_to_move(): + """ + Block until ``time.monotonic()`` reports a new value. + + The deadline a store computes works out to the mark taken when the + handshake began plus the lifetime the peer announced -- the "now" in + ``lifetime - (now - mark)`` and the one in ``now + lifetime`` cancel out -- + so two handshakes the clock cannot tell apart are given the same deadline. + That is the right answer, there being no difference to record, but it + leaves nothing for a test comparing two deadlines to see. Windows resolves + this clock to about ten milliseconds, which two loopback handshakes fit + inside comfortably. + """ + started = time.monotonic() + while time.monotonic() == started: + time.sleep(0.001) + + +class _TLSEchoServer(object): + """ + A TLS server on loopback that echoes back whatever a client sends. Each + accepted connection is served on its own thread, so a batch of clients can + handshake concurrently. + """ + + def __init__(self, cert_path, key_path, tls_version): + self.context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + self.context.load_cert_chain(cert_path, key_path) + self.context.minimum_version = tls_version + self.context.maximum_version = tls_version + + self._listener = socket.socket() + self._listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self._listener.bind(('127.0.0.1', 0)) + self._listener.listen(16) + self._listener.settimeout(0.1) + self.port = self._listener.getsockname()[1] + + self._stop = threading.Event() + self._accept_thread = threading.Thread(target=self._accept_loop, daemon=True) + self._accept_thread.start() + + def _accept_loop(self): + while not self._stop.is_set(): + try: + client, _ = self._listener.accept() + except socket.timeout: + continue + except OSError: + return + threading.Thread(target=self._serve, args=(client,), daemon=True).start() + + def _serve(self, client): + try: + tls_client = self.context.wrap_socket(client, server_side=True) + while True: + data = tls_client.recv(64) + if not data: + return + tls_client.sendall(data) + except OSError: + pass + finally: + try: + client.close() + except OSError: + pass + + def close(self): + self._stop.set() + self._accept_thread.join(timeout=5) + self._listener.close() + + +class _SocketOnlyConnection(Connection): + """ + A connection that performs only the socket and TLS part of setup. The CQL + handshake is stood in for by an echo exchange, which is enough to have a + TLS 1.3 server's NewSessionTicket read off the socket, exactly as the + OPTIONS/STARTUP exchange does in a real connection. + """ + + def __init__(self, *args, **kwargs): + Connection.__init__(self, *args, **kwargs) + self._connect_socket() + + def exchange(self): + self._socket.sendall(b'ping') + assert self._socket.recv(4) == b'ping' + + def close(self): + if self._socket is not None: + try: + self._socket.close() + except OSError: + pass + + @property + def session_reused(self): + return self._socket.session_reused + + +@unittest.skipIf(not HAVE_CRYPTOGRAPHY, + 'cryptography is required to generate a test certificate') +class TlsResumptionTest(unittest.TestCase): + + tls_version = ssl.TLSVersion.TLSv1_2 + + @classmethod + def setUpClass(cls): + cls._cert_dir = tempfile.TemporaryDirectory(prefix='tls_resumption_') + cls.addClassCleanup(cls._cert_dir.cleanup) + cls._cert_path, cls._key_path = write_self_signed_cert(cls._cert_dir.name) + # A second pair, for a server the client context will not trust. + cls._untrusted_dir = tempfile.TemporaryDirectory(prefix='tls_untrusted_') + cls.addClassCleanup(cls._untrusted_dir.cleanup) + cls._untrusted_cert, cls._untrusted_key = write_self_signed_cert( + cls._untrusted_dir.name) + + def setUp(self): + self.server = _TLSEchoServer(self._cert_path, self._key_path, self.tls_version) + self.addCleanup(self.server.close) + self.cache = SSLSessionCache() + self.connections = [] + + def make_ssl_context(self): + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.load_verify_locations(self._cert_path) + context.verify_mode = ssl.CERT_REQUIRED + context.check_hostname = True + return context + + def untrusted_server(self): + """ + A TLS server whose certificate the client context does not trust, so + the handshake fails during verification. + + Failing that way rather than by feeding a listener non-TLS bytes keeps + the failure a TLS one on every platform: bytes sent and the connection + then closed is a race between OpenSSL reading the bad record and the + socket reporting the close, and Windows reports the close first + (WSAECONNABORTED), which is not a TLS error at all. + """ + server = _TLSEchoServer(self._untrusted_cert, self._untrusted_key, + self.tls_version) + self.addCleanup(server.close) + return server + + def connect(self, ssl_context, cache=None, exchange=True, ssl_options=None): + connection = _SocketOnlyConnection( + DefaultEndPoint('127.0.0.1', self.server.port), + ssl_context=ssl_context, + ssl_options=ssl_options, + ssl_session_cache=self.cache if cache is None else cache, + connect_timeout=10) + self.connections.append(connection) + self.addCleanup(connection.close) + if exchange: + connection.exchange() + return connection + + def test_a_second_connection_resumes_the_first_session(self): + context = self.make_ssl_context() + + first = self.connect(context) + assert not first.session_reused + first._store_tls_session() + assert len(self.cache) == 1 + + second = self.connect(context) + + assert second.session_reused + + def test_concurrent_connections_all_resume_one_cached_session(self): + # This is the case DRIVER-165 is about: a pool opens one connection per + # shard at once, and they all have to be able to offer the session + # cached by an earlier connection to the same node. + context = self.make_ssl_context() + self.connect(context)._store_tls_session() + + resumed = [] + barrier = threading.Barrier(4) + + def connect_and_record(): + barrier.wait() + resumed.append(self.connect(context, exchange=False).session_reused) + + threads = [threading.Thread(target=connect_and_record) for _ in range(4)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=30) + + assert resumed == [True] * 4 + + def test_no_resumption_without_a_cache(self): + context = self.make_ssl_context() + self.connect(context)._store_tls_session() + + without_cache = _SocketOnlyConnection( + DefaultEndPoint('127.0.0.1', self.server.port), + ssl_context=context, ssl_session_cache=None, connect_timeout=10) + self.addCleanup(without_cache.close) + + assert not without_cache.session_reused + + def test_a_cached_session_pins_its_context_until_the_cache_drops_it(self): + # A real SSLSession holds a strong reference to the SSLContext it was + # established with, so an entry keeps that context -- and everything + # reachable from it -- alive. A cache the driver created for a cluster + # is dropped with it; one the caller supplied outlives it, holding the + # contexts of at most max_size peers until clear() drops them. + # Built directly rather than through self.connect(), whose bookkeeping + # would hold the connection, and so the context, itself. + context = self.make_ssl_context() + connection = _SocketOnlyConnection( + DefaultEndPoint('127.0.0.1', self.server.port), + ssl_context=context, ssl_session_cache=self.cache, + connect_timeout=10) + connection.exchange() + connection._store_tls_session() + key = connection._tls_session_cache_key() + assert self.cache.get(key) is not None + weak = weakref.ref(context) + + connection.close() + del context, connection, key + gc.collect() + # The entry still holds it, which is the retention the cache documents. + assert weak() is not None + + self.cache.clear() + gc.collect() + + assert weak() is None + + def test_a_connection_that_resumed_remembers_what_it_offered(self): + # Nothing is left to retract once the handshake stands, but the store + # reads what was offered: handing it to the cache is what tells a + # session the peer reissued from the one that came back unchanged. + context = self.make_ssl_context() + first = self.connect(context) + first._store_tls_session() + offered = self.cache.get(first._tls_session_cache_key()) + + resumed = self.connect(context) + + assert resumed.session_reused + assert resumed._tls_session_offered.id == offered.id + + def test_an_attempt_that_offers_nothing_clears_what_came_before(self): + context = self.make_ssl_context() + connection = self.connect(context) + connection._tls_session_offered = 'from an earlier address' + + # Nothing cached for this key, so nothing is offered. + connection._restore_tls_session(Mock()) + + assert connection._tls_session_offered is None + + def test_a_failed_handshake_drops_the_session_it_offered(self): + # Nothing stores a session for a connection that never came up, so an + # entry that provokes a handshake failure would be offered again by + # every later connection until its lifetime ran out. + context = self.make_ssl_context() + donor = self.connect(context) + donor._store_tls_session() + + rejecting = self.untrusted_server() + endpoint = DefaultEndPoint('127.0.0.1', rejecting.port) + # A Connection built without connecting, just to ask for the key the + # failing connection below will use. + key = Connection(endpoint, ssl_context=context, + ssl_session_cache=self.cache)._tls_session_cache_key() + self.cache.set(key, self.cache.get(donor._tls_session_cache_key())) + assert self.cache.get(key) is not None + + # _connect_socket re-raises as socket.error(errno, ...), so the + # SSLError type does not survive -- only its message. + with pytest.raises(OSError, match='SSL'): + _SocketOnlyConnection(endpoint, ssl_context=context, + ssl_session_cache=self.cache, connect_timeout=10) + + assert self.cache.get(key) is None + + def test_a_failed_handshake_spares_a_session_stored_meanwhile(self): + # Connections to one node are opened together, so another may store a + # fresh session under this key between the offer and the failure. That + # one did not fail anything and has to stay. + context = self.make_ssl_context() + donor = self.connect(context) + donor._store_tls_session() + + rejecting = self.untrusted_server() + endpoint = DefaultEndPoint('127.0.0.1', rejecting.port) + key = Connection(endpoint, ssl_context=context, + ssl_session_cache=self.cache)._tls_session_cache_key() + self.cache.set(key, self.cache.get(donor._tls_session_cache_key())) + + # Stand in for the connection that succeeds while this one is failing. + class Refresher(_SocketOnlyConnection): + def _set_tls_session(self, sock, session): + super()._set_tls_session(sock, session) + self._ssl_session_cache.set(key, 'stored-by-another-connection') + + with pytest.raises(OSError, match='SSL'): + Refresher(endpoint, ssl_context=context, + ssl_session_cache=self.cache, connect_timeout=10) + + assert self.cache.get(key) == 'stored-by-another-connection' + + def test_a_session_is_not_offered_to_a_different_server_name(self): + # A resumed handshake carries no Certificate, so the name the peer was + # verified against is never checked again. A session established for + # one name must therefore never be offered to a connection expecting + # another, even though both reach the same address and port. + context = self.make_ssl_context() + context.check_hostname = False + self.connect(context, ssl_options={'server_hostname': 'one.example'})._store_tls_session() + + same_name = self.connect(context, ssl_options={'server_hostname': 'one.example'}) + other_name = self.connect(context, ssl_options={'server_hostname': 'other.example'}) + + assert same_name.session_reused + assert not other_name.session_reused + + def test_a_session_is_not_offered_to_a_different_context(self): + # A session can only be replayed onto the context it was established + # with -- the stdlib ssl module rejects anything else -- so the context + # is part of the cache key. + self.connect(self.make_ssl_context())._store_tls_session() + + second = self.connect(self.make_ssl_context()) + + assert not second.session_reused + + def test_what_a_resumed_handshake_stores_back(self): + # SSLSocket.session builds a new object on every access, so comparing + # object identity here would pass whatever happened. What matters is + # whether the peer issued a new session: below TLS 1.3 an abbreviated + # handshake hands back the one that was offered, and its deadline has + # to stay where it was rather than start again on every reuse. + context = self.make_ssl_context() + first = self.connect(context) + first._store_tls_session() + # Ask the connection for its key rather than rebuilding it here, so this + # test does not depend on the key's shape. + key = first._tls_session_cache_key() + first_id = self.cache.get(key).id + first_deadline = self.cache._sessions[key][1] + + # Otherwise both handshakes may begin within one tick of the clock, and + # a deadline computed afresh is indistinguishable from an inherited one. + _wait_for_the_monotonic_clock_to_move() + resumed = self.connect(context) + assert resumed.session_reused + resumed._store_tls_session() + + renewed = self.tls_version >= ssl.TLSVersion.TLSv1_3 + assert (self.cache.get(key).id != first_id) is renewed + assert (self.cache._sessions[key][1] > first_deadline) is renewed + + +@unittest.skipUnless(ssl.HAS_TLSv1_3, 'this build of OpenSSL has no TLS 1.3') +class Tls13ResumptionTest(TlsResumptionTest): + """ + The same coverage over TLS 1.3, plus what is specific to it. + + Every inherited test drives a TLS 1.3 server, so a build without it has to + skip the class rather than fail each handshake. The guard sits here and not + on the base class: skipping a base skips its subclasses too, which would + take these tests out on a build that has TLS 1.3 but not 1.2. + """ + + tls_version = ssl.TLSVersion.TLSv1_3 + + def test_the_session_is_only_stored_once_the_ticket_has_arrived(self): + # A TLS 1.3 server sends its NewSessionTicket after the handshake, so a + # session read before the first application-data exchange carries no + # ticket and must not be cached. + connection = self.connect(self.make_ssl_context(), exchange=False) + + assert connection._socket.version() == 'TLSv1.3' + assert connection._get_resumable_tls_session() is None + connection._store_tls_session() + assert len(self.cache) == 0 + + connection.exchange() + + assert connection._get_resumable_tls_session() is not None + connection._store_tls_session() + assert len(self.cache) == 1 From d740f4bf224304c33ebeb665c29226d8a049fda1 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Thu, 17 Sep 2026 16:09:25 +0200 Subject: [PATCH 179/200] Add an integration test for TLS session resumption Restart the cluster with client encryption on, warm a session cache with one cluster, then hand it to a second one and require every connection it opens to have resumed -- which is the question only a real server can answer: whether it accepts one session offered concurrently by the whole batch of per-shard connections. The cluster is given a shard-aware TLS port, since that is the port those per-shard connections use and therefore where resumption has to pay off; Scylla leaves it unset by default. The certificate names every node rather than only the contact point, or the driver could not build pools to the rest of the cluster and the test would quietly examine a single host. Each Session is held for the duration of a test: Cluster.sessions is a WeakSet, so a dropped Session takes its pools -- everything worth inspecting -- with it and leaves only the control connection behind. The number of connections collected is asserted before their resumption flags, so the test cannot pass by examining almost nothing. A connection that has lost its socket is left out of the reading rather than counted as not having resumed -- SSLSocket.session_reused answers None once the socket is closed, which would read as a resumption failure for a connection that is simply gone -- and it is that count which keeps leaving one out from quietly shrinking the sample. The key and certificate outlive the run when KEEP_TEST_CLUSTER does: remove_cluster() is a no-op there, and what it leaves behind still names those files. Whether there is anything here to test at all depends on the reactor, so the skip asks the connection class Cluster will instantiate rather than reading EVENT_LOOP_MANAGER: with no selector set and no libev to import, that class is the asyncio reactor, which cannot restore a session before the handshake. Follows the reconfigure-and-remove pattern the other modules here use for cluster-level options, and generates the server certificate with cryptography, through the generator the loopback suite also uses, so the test does not depend on an openssl binary. Refs DRIVER-165 --- .../standard/test_tls_resumption.py | 231 ++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 tests/integration/standard/test_tls_resumption.py diff --git a/tests/integration/standard/test_tls_resumption.py b/tests/integration/standard/test_tls_resumption.py new file mode 100644 index 0000000000..f0f2e3af3a --- /dev/null +++ b/tests/integration/standard/test_tls_resumption.py @@ -0,0 +1,231 @@ +# Copyright 2026 ScyllaDB, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +TLS session resumption against a real, TLS-enabled Scylla cluster. + +The mechanics of resumption are covered by +``tests/unit/test_tls_resumption.py`` against a local TLS server. What needs +a real cluster is whether the *server* accepts one session offered by several +connections at once, which is the case DRIVER-165 is about: a pool opens one +connection per shard and they all offer the same cached session. +""" + +import logging +import shutil +import ssl +import tempfile +import unittest + +from cassandra.cluster import Cluster +from cassandra.ssl_session_cache import SSLSessionCache +from tests.integration import (use_singledc, get_cluster, remove_cluster, + start_cluster_wait_for_up, KEEP_TEST_CLUSTER, + SCYLLA_VERSION, TestCluster) +from tests.tls_certificates import HAVE_CRYPTOGRAPHY, write_self_signed_cert +from tests.util import wait_until + +log = logging.getLogger(__name__) + +_cert_dir = None +_cert_path = None +_key_path = None + + + +def setup_module(): + """ + Restart the shared cluster with client encryption enabled, the way other + modules in this directory reconfigure it (see test_custom_cluster). + teardown_module drops it so the next module gets a clean one. + """ + if SCYLLA_VERSION is None: + raise unittest.SkipTest( + 'client_encryption_options are configured the Scylla way here; ' + 'set SCYLLA_VERSION to run this') + # Asked of the class TestCluster will actually use, not of the selector: + # tests.integration sets Cluster.connection_class from EVENT_LOOP_MANAGER + # only when it resolved one, and leaves the driver's own default in place + # otherwise -- which, with no selector and no libev, is the asyncio reactor. + reactor = Cluster.connection_class + if not getattr(reactor, 'supports_tls_session_resumption', False): + raise unittest.SkipTest( + '%s cannot restore a cached TLS session before the handshake, so ' + 'there is no resumption here to test' + % getattr(reactor, '__name__', reactor)) + if not HAVE_CRYPTOGRAPHY: + raise unittest.SkipTest( + 'cryptography is required to generate a server certificate') + + global _cert_dir, _cert_path, _key_path + _cert_dir = tempfile.mkdtemp(prefix='tls_resumption_') + try: + use_singledc(start=False) + ccm_cluster = get_cluster() + ccm_cluster.stop() + # The certificate has to name every node, so it can only be issued once + # the cluster exists. + _cert_path, _key_path = write_self_signed_cert( + _cert_dir, [node.address() for node in ccm_cluster.nodelist()]) + ccm_cluster.set_configuration_options({ + # Per-shard connections go to this port, which is where resumption + # has to pay off; Scylla leaves it unset by default. + 'native_shard_aware_transport_port_ssl': 19142, + 'client_encryption_options': { + 'enabled': True, + 'certificate': _cert_path, + 'keyfile': _key_path, + # Set rather than assumed: this defaults to false before + # Scylla 2026.3, and without it the server issues no + # NewSessionTicket and nothing can be resumed. + 'enable_session_tickets': True, + } + }) + start_cluster_wait_for_up(ccm_cluster) + except Exception: + # pytest skips teardown_module when setup_module raises, so undo both + # halves here: the cluster would otherwise be left stopped and still + # configured for TLS for every module that runs after this one, and the + # key and certificate would be left behind on disk. + try: + remove_cluster() + finally: + _discard_certificate() + raise + + +def _discard_certificate(): + """ + Remove the key and certificate, unless the cluster that was configured to + use them is being kept. + + KEEP_TEST_CLUSTER makes remove_cluster() a no-op, and what it leaves behind + still names these files in its client_encryption_options: deleting them + would leave a cluster that cannot be started again, which is the one thing + that mode exists to avoid. + """ + global _cert_dir + if _cert_dir is None: + return + if KEEP_TEST_CLUSTER: + log.info('Keeping the TLS key and certificate in %s: the cluster kept ' + 'by KEEP_TEST_CLUSTER is still configured to use them.', + _cert_dir) + else: + shutil.rmtree(_cert_dir, ignore_errors=True) + _cert_dir = None + + +def teardown_module(): + try: + remove_cluster() + finally: + _discard_certificate() + + +def make_ssl_context(): + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.load_verify_locations(_cert_path) + context.verify_mode = ssl.CERT_REQUIRED + context.check_hostname = True + return context + + +def resumption_of_every_connection(cluster): + """ + What OpenSSL reports for each of the cluster's live connections: a list of + ``session_reused`` flags, one per connection that still has a socket. + + A connection that went away between being collected here and being read is + left out rather than reported: SSLSocket.session_reused answers None once + the socket is closed, which reads as "did not resume" and would fail an + assertion about a connection that is no longer there. The count is + asserted separately, so leaving one out cannot quietly shrink the sample. + """ + return [bool(connection._socket.session_reused) + for holder in cluster.get_connection_holders() + for connection in holder.get_connections() + if connection._socket is not None + and not (connection.is_closed or connection.is_defunct)] + + +def expected_connection_count(cluster): + """ + One control connection, plus one pool connection per shard of every host + the driver considers up. + """ + return 1 + sum(host.sharding_info.shards_count if host.sharding_info else 1 + for host in cluster.metadata.all_hosts() if host.is_up) + + +def collect_resumption(cluster): + """ + Wait for the pools to fill, then report whether each connection resumed a + TLS session. The wait and the count assertion matter: per-shard + connections are opened in the background, so an assertion made too early + would run against a fraction of them -- or against the control connection + alone -- and pass without testing anything. + """ + expected = expected_connection_count(cluster) + wait_until(lambda: len(resumption_of_every_connection(cluster)) >= expected, 0.5, 40) + + resumed = resumption_of_every_connection(cluster) + log.info('%d of %d connections resumed a TLS session (expected at least %d)', + sum(resumed), len(resumed), expected) + assert len(resumed) >= expected, \ + 'inspected %d connections, expected at least %d' % (len(resumed), expected) + return resumed + + +class TLSSessionResumptionTests(unittest.TestCase): + + def setUp(self): + # Cluster.sessions is a WeakSet and HostConnection keeps only a + # weakref.proxy to its session, so a Session nobody holds is collected + # and takes the pools -- everything worth inspecting -- with it. + self._sessions = [] + + def connect(self, **kwargs): + cluster = TestCluster(**kwargs) + self.addCleanup(cluster.shutdown) + self._sessions.append(cluster.connect(wait_for_all_pools=True)) + return cluster + + def test_resumption_is_on_by_default_with_an_ssl_context(self): + cluster = self.connect(ssl_context=make_ssl_context()) + + assert isinstance(cluster.ssl_session_cache, SSLSessionCache) + assert len(cluster.ssl_session_cache) > 0 + + def test_every_connection_resumes_from_a_warmed_cache(self): + # Warm a cache, then hand it to a second cluster using the same + # SSLContext. Every connection that cluster opens -- including the + # whole batch of per-shard connections opened at once, which reach the + # node on its shard-aware port -- then has a session to offer, so the + # server has to accept the same one from all of them concurrently. + context = make_ssl_context() + cache = SSLSessionCache() + self.connect(ssl_context=context, ssl_session_cache=cache) + + cluster = self.connect(ssl_context=context, ssl_session_cache=cache) + + assert all(collect_resumption(cluster)) + + def test_nothing_resumes_when_the_cache_is_disabled(self): + context = make_ssl_context() + self.connect(ssl_context=context, ssl_session_cache=SSLSessionCache()) + + cluster = self.connect(ssl_context=context, ssl_session_cache=None) + + assert cluster.ssl_session_cache is None + assert not any(collect_resumption(cluster)) From ff3c5c7f5ec855f3f63ba8657f2d9f9d8432b21e Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Tue, 22 Sep 2026 09:53:11 +0200 Subject: [PATCH 180/200] Document TLS session resumption where TLS is configured Resumption is on by default and has prerequisites on both sides, which until now were described only in the ssl_session_cache docstring -- read by someone already looking at the attribute, rather than by someone setting up TLS. Give it a section in the security guide, next to the SSL configuration it belongs with, covering what the server and the reactor each have to provide -- and what resumption costs, which is the part a security guide owes a reader most. Scylla only issues session tickets when enable_session_tickets is set in client_encryption_options. That defaults to true from Scylla 2026.3 and to false in the releases before it, so on an earlier one a cluster has to be told to issue them; without them the cache stays empty and every connection performs a full handshake, with no indication of why. What it costs is verification. A resumed handshake carries no Certificate message, so nothing about the server's certificate is checked again while a cached session is offered -- not its expiry, and not a revocation list the context carries -- and one that expires or is revoked goes on being accepted until the entry does, bounded by the lifetime the server announced and the driver's seven-day cap. The hostname is the exception, and only because the cache is keyed by the name verified when the session was established, so a session is never offered where a different name is expected. Someone who cannot accept that window is told how to turn resumption off. The reactor and server paragraphs move off the class rather than being copied, leaving the docstring to the semantics of the attribute -- what it holds, whose it is, how to turn it off or size it -- and a pointer to the guide for what has to be true for any of it to happen. Refs DRIVER-165 --- cassandra/cluster.py | 24 ++++++---------- docs/security.rst | 65 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 16 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 76eb148be7..9169936072 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -1735,22 +1735,14 @@ def ssl_session_cache(self): cluster = Cluster(ssl_context=ssl_context, ssl_session_cache=SSLSessionCache(max_size=64)) - Resumption is available when TLS is configured through - :attr:`~Cluster.ssl_context` and the reactor establishes TLS with the - standard library's ``ssl`` module: the ``libev`` reactor, and ``asyncore`` - on the Python versions that still ship it, which is up to 3.11. Which of - them is the default depends on what can be imported -- libev first, then - asyncore, then asyncio -- so on Python 3.12 and newer without the libev - extension the default is the ``asyncio`` reactor, and resumption is off. - - It is not available with the deprecated :attr:`~Cluster.ssl_options`-only - configuration, because each connection builds its own ``SSLContext`` and a - session cannot be replayed onto a different one; nor on the ``asyncio`` - reactor, which performs the handshake inside - ``loop.create_connection()``, leaving no point at which to restore a - session. In those cases no cache is created and connections handshake in - full. -""" + What resumption asks of the reactor, and of the server -- Scylla issues + session tickets only when told to, before 2026.3 -- is described under + :ref:`security`. Where any of that is missing no cache is created and + this reads as :const:`None`, and connections handshake in full as they + did before. Over TLS 1.2 a server that issues no tickets still assigns + a session id, so the cache may hold an entry it will not honour; + offering that costs nothing and the handshake simply completes in + full.""" if not self._tls_session_resumption_available(): return None if self._ssl_session_cache is not _NOT_SET: diff --git a/docs/security.rst b/docs/security.rst index 3cbbcbbb40..caa10bf288 100644 --- a/docs/security.rst +++ b/docs/security.rst @@ -65,6 +65,71 @@ keystore files with these instructions: * `Scylla TLS/SSL Guide `_ +TLS Session Resumption +^^^^^^^^^^^^^^^^^^^^^^ +A shard-aware driver opens one connection per shard to every node, and a full +TLS handshake on each is the expensive part of that. Whenever +:attr:`.Cluster.ssl_context` is set, the driver caches the TLS session each +connection establishes and offers it on the next one, so the connections that +follow resume instead of handshaking in full. This is on by default and needs no +configuration. + +There is one thing to weigh before leaving it on. A resumed handshake carries +no Certificate message, so nothing about the server's certificate is checked +again while a cached session is being offered -- not its expiry, and not a +revocation list the ``SSLContext`` carries. A certificate that expires or is +revoked goes on being accepted by connections that resume, until the cached +entry goes: that is the lifetime the server announced with the ticket, which +the driver caps at seven days. Connections that handshake in full verify as +they always have. The hostname is not re-checked either, which is why the +driver keys each cached session by the name verified when it was established, +so a session is never offered to a connection expecting a different one. Where +that window is not acceptable, turn resumption off with +``ssl_session_cache=None``. + +It does need the server to issue something to resume from. Scylla sends session +tickets only when ``enable_session_tickets`` is set in its +``client_encryption_options``, which defaults to true from Scylla 2026.3 and to +false in the releases before it: + +.. code-block:: yaml + + client_encryption_options: + enabled: true + certificate: /path/to/scylla.crt + keyfile: /path/to/scylla.key + enable_session_tickets: true + +Without that, nothing resumes and every connection performs a full handshake, as +it did before. + +Resumption also needs a reactor that can offer a session before the handshake +begins: the ``libev`` reactor, and ``asyncore`` on the Python versions that still +ship it, which is up to 3.11. The ``asyncio`` reactor performs its handshake +inside ``loop.create_connection()``, leaving no point at which to restore a +session, so resumption is unavailable there -- worth knowing, because that is the +default reactor on Python 3.12 and newer when the libev extension is not +installed. It is unavailable too with the deprecated +:attr:`.Cluster.ssl_options`-only configuration below, since each of those +connections builds its own ``SSLContext`` and a session cannot be replayed onto a +different one. + +Where resumption is unavailable, no cache is created and +:attr:`.Cluster.ssl_session_cache` reads as ``None``; asking for one anyway is +reported when :meth:`.Cluster.connect` is called. To turn resumption off, or to +size the cache or share it between clusters, see +:attr:`.Cluster.ssl_session_cache`: + +.. code-block:: python + + from cassandra.cluster import Cluster + from cassandra.ssl_session_cache import SSLSessionCache + + cluster = Cluster(ssl_context=ssl_context, ssl_session_cache=None) + + cluster = Cluster(ssl_context=ssl_context, + ssl_session_cache=SSLSessionCache(max_size=64)) + SSL Configuration Examples ^^^^^^^^^^^^^^^^^^^^^^^^^^ Here, we'll describe the server and driver configuration necessary to set up SSL to meet various goals, such as the client verifying the server and the server verifying the client. We'll also include Python code demonstrating how to use servers and drivers configured in these ways. From a84f297afd1478ef7ed27e87947d7ba692b92d78 Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Mon, 21 Sep 2026 22:37:14 -0400 Subject: [PATCH 181/200] control-connection: preserve fallback session keyspace Bind application use of the shared control connection to one keyspace so fallback queries from different sessions cannot leak USE state into one another. Reject explicit USE and select the bound keyspace before sending application requests. Reclaim the binding only after its owners are gone and its fallback requests have drained. Track that traffic independently from control traffic, unwind a new claim when no request is sent, and keep protocol encoding outside the binding lock. Fixes #1013. --- CHANGELOG.rst | 27 + cassandra/cluster.py | 353 ++++++++++-- .../test_control_connection_query_fallback.py | 90 +++ tests/unit/test_cluster.py | 9 +- tests/unit/test_response_future.py | 516 +++++++++++++++++- 5 files changed, 944 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index bd2f2b28be..5ef0aff5b7 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -29,6 +29,33 @@ Features statements skip re-sending result metadata on EXECUTE, and the driver automatically refreshes cached metadata when the server detects a schema change (DRIVER-153) +Bug Fixes +--------- +* Prevent sessions from leaking keyspace state when application queries fall back to + the shared control connection. The first fallback session binds that connection to + its keyspace (including no keyspace); other fallback sessions with a different + keyspace are rejected while that binding is held. The binding is released once + every session holding it has been shut down or garbage collected and its fallback + requests have drained, so a later session can take it over. Taking it over from a + session without a keyspace is rejected when the previous holder left the shared + connection in a keyspace, since CQL offers no way back to "no keyspace". An explicit + ``USE`` statement -- including the one ``Session.set_keyspace()`` executes -- is now + rejected with ``InvalidRequest`` on the fallback path, since it would change the + keyspace of the shared connection under every other session using it; the keyspace + has to be chosen when the session is created (#1013). +* Fix the client-side timeout never firing for a request on the control-connection + fallback path while the driver's own ``USE`` is in flight. The ``USE`` is sent + without recording a host attempt, and ``_on_speculative_execute`` checked its + "no attempt recorded yet" guard before the deadline, so with a speculative + execution policy configured a repeatedly failing ``USE`` rescheduled the callback + every 10ms instead of ever timing the request out. The deadline is now checked + first (#1013). +* Fix ``ResponseFuture._req_id`` being left pointing at the driver's own ``USE`` + instead of the request actually in flight, when the ``SET_KEYSPACE`` reply is + processed before ``send_msg()`` returns. On a later timeout the driver then + orphaned the wrong stream id and left the real request in the connection's + request map (#1013). + Others ------ * ``DCAwareRoundRobinPolicy.local_dc`` is now read-only. It is set by the constructor, diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 9169936072..181fdf47b8 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -45,7 +45,7 @@ from cassandra import (ConsistencyLevel, AuthenticationFailed, OperationTimedOut, UnsupportedOperation, SchemaTargetType, DriverException, ProtocolVersion, - UnresolvableContactPoints, DependencyException) + UnresolvableContactPoints, DependencyException, InvalidRequest) from cassandra.auth import _proxy_execute_key, PlainTextAuthProvider from cassandra.client_routes import ClientRoutesChangeType, ClientRoutesConfig, _ClientRoutesHandler from cassandra.connection import (ClientRoutesEndPointFactory, ConnectionException, ConnectionShutdown, @@ -595,6 +595,22 @@ class ControlConnectionQueryFallback(enum.Enum): ``SkipPoolCreation`` disables node-pool creation for the session and uses the control-connection fallback path for application queries. + The first session using fallback binds the shared control connection to its + keyspace, including :const:`None`. Other sessions may use fallback only + while their keyspace matches that binding; a different keyspace is + rejected. The binding is released once every session holding it has been + shut down or garbage collected and its fallback requests have drained, + after which a later session may take it over. A session without a keyspace + cannot take over a binding that left the shared connection in a keyspace, + because CQL offers no way back to "no keyspace"; that case is rejected with + :class:`.InvalidRequest`. + + An explicit ``USE`` statement -- including the one + :meth:`.Session.set_keyspace` executes -- is rejected with + :class:`.InvalidRequest` on the fallback path, because it would change the + keyspace of the shared connection under every other session using it. The + keyspace has to be chosen when the session is created. + The fallback path is not used for requests targeted to an explicit host. """ @@ -926,6 +942,8 @@ def default_retry_policy(self, policy): ``Disabled`` keeps the old behavior. ``Fallback`` enables control-connection fallback when no usable node pools exist. ``SkipPoolCreation`` skips node-pool creation and uses the control connection fallback path. + The first fallback session binds the shared control connection to its keyspace; + later fallback sessions must use the same keyspace. This fallback is still not used for requests targeted to an explicit host. """ @@ -2797,6 +2815,18 @@ def __init__(self, cluster, hosts, keyspace=None): msg += " using keyspace '%s'" % self.keyspace raise NoHostAvailable(msg, [h.address for h in hosts]) + # Only SkipPoolCreation is a fallback Session for its whole life, so + # only it takes the binding up front. Under Fallback the pools may well + # come up moments later, and _query_control_connection() takes the + # binding if and when a query actually needs the shared connection; + # claiming it here would fail connect() over a transient blip and then + # hold the binding for the Session's lifetime. + if fallback_mode is ControlConnectionQueryFallback.SkipPoolCreation: + control_connection = self.cluster.control_connection + conflict = control_connection._attach_application_session(self.keyspace, self) + if conflict is not None: + raise InvalidRequest(conflict) + self.session_id = uuid.uuid4() if self.cluster.column_encryption_policy is not None: @@ -3591,6 +3621,12 @@ def set_keyspace(self, keyspace): """ Set the default keyspace for all queries made through this Session. This operation blocks until complete. + + Raises :class:`.InvalidRequest` when the session uses the + control-connection fallback path (see + :class:`.ControlConnectionQueryFallback`), where the keyspace of the + shared connection cannot be changed; create a Session with the wanted + keyspace instead. """ self.execute('USE %s' % (protect_name(keyspace),)) @@ -3981,6 +4017,20 @@ def __init__(self, cluster, timeout, self._event_schedule_times = {} + # The first fallback Session binds application use of the shared + # control connection to one keyspace (including None). Keeping that + # binding stable avoids connection-level USE state leaking between + # Sessions without adding a dispatcher to this exceptional path. + # The binding is released once every Session holding it is gone and its + # requests have drained, so a later Session can take it over. Response + # callbacks keep this lock while handing one fallback request off to + # the next; it must be re-entrant because some connections deliver a + # response synchronously from send_msg(). + self._application_query_lock = RLock() + self._application_keyspace = _NOT_SET + self._application_sessions = WeakSet() + self._application_requests_in_flight = 0 + def connect(self): if self._is_shutdown: return @@ -4002,6 +4052,73 @@ def _set_new_connection(self, conn): log.debug("[control connection] Closing old connection %r, replacing with %r", old, conn) old.close() + def _attach_application_session(self, keyspace, session): + """Bind application use of the control connection to ``keyspace``. + + The first fallback ``Session`` takes the binding; other Sessions may + share it while they use the same keyspace. Once every Session holding + the binding has been shut down or collected and its fallback requests + have drained, the binding is reclaimed and a later Session can take it + over. + + Returns ``None`` when the binding was taken or shared, otherwise a + message explaining the conflict. + """ + with self._application_query_lock: + self._prune_application_sessions() + + # A Session already holding the binding is not in conflict with + # itself: it may rebind to the keyspace it switched to. + if any(other is not session for other in self._application_sessions): + if self._application_keyspace != keyspace: + return ("Control-connection fallback is already attached to " + "keyspace %r; cannot use it from a Session using " + "keyspace %r" % (self._application_keyspace, keyspace)) + elif keyspace is None: + # Reclaiming from a gone Session cannot undo the USE it left on + # the shared connection: CQL has no way back to "no keyspace". + leftover = self._leftover_application_keyspace() + if leftover is not None: + return ("Control-connection fallback was attached to keyspace " + "%r by a Session that is gone, and the shared " + "connection cannot be reset to no keyspace; create a " + "Session using keyspace %r instead" % (leftover, leftover)) + + self._application_keyspace = keyspace + self._application_sessions.add(session) + return None + + def _discard_application_session(self, session): + """Undo an attachment when its first fallback request was not sent.""" + with self._application_query_lock: + self._application_sessions.discard(session) + if not self._application_sessions and \ + not self._application_requests_in_flight: + self._application_keyspace = _NOT_SET + + def _prune_application_sessions(self): + """Drop shut-down owners after shared-connection requests have drained.""" + if self._application_requests_in_flight: + return + for session in tuple(self._application_sessions): + if session.is_shutdown: + self._application_sessions.discard(session) + + def _leftover_application_keyspace(self): + """The keyspace a released binding left the shared connection in, if any.""" + if self._application_keyspace is _NOT_SET or self._application_keyspace is None: + return None + connection = self._connection + # A reconnect replaces the connection, and a fresh one starts out with + # no keyspace, so the leftover USE state went away with the old one. + if connection is None or connection.keyspace is None: + return None + return connection.keyspace + + def _get_application_keyspace(self): + with self._application_query_lock: + return self._application_keyspace + def _try_connect_to_hosts(self): errors = {} @@ -4196,11 +4313,12 @@ def shutdown(self): return else: self._is_shutdown = True - log.debug("Shutting down control connection") - if self._connection: - self._connection.close() - self._connection = None + connection = self._connection + self._connection = None + + if connection: + connection.close() def refresh_schema(self, force=False, **kwargs): try: @@ -5123,6 +5241,18 @@ def _on_speculative_execute(self): self._timer = None if not self._event.is_set(): + # Check the deadline before the PYTHON-836 guard below. That guard + # only exists to keep speculative queries from running ahead of the + # main thread's first query; it must not swallow an expired client + # timeout. The driver's own USE on the control-connection fallback + # path is sent with record_attempt=False, so attempted_hosts stays + # empty for its whole round trip - and if that USE keeps failing and + # retrying, the 0.01s reschedule below would spin forever instead of + # ever timing the request out. + if self._time_remaining is not None and self._time_remaining <= 0: + self._on_timeout() + return + # PYTHON-836, the speculative queries must be after # the query is sent from the main thread, otherwise the # query from the main thread may raise NoHostAvailable @@ -5134,10 +5264,6 @@ def _on_speculative_execute(self): self._timer = self.session.cluster.connection_class.create_timer(0.01, self._on_speculative_execute) return - if self._time_remaining is not None: - if self._time_remaining <= 0: - self._on_timeout() - return self.send_request(error_no_hosts=False) self._start_timer() @@ -5168,8 +5294,12 @@ def send_request(self, error_no_hosts=True): if error_no_hosts: if self._fallback_to_control_connection(): req_id = self._query_control_connection() + if req_id is _NOT_SET: + return True if req_id is not None: - self._req_id = req_id + # _send_control_connection_message() already recorded the + # id of the message actually in flight. Re-assigning here + # would clobber it with the USE id on the keyspace path. return True self._set_final_exception(NoHostAvailable( @@ -5195,26 +5325,103 @@ def _fallback_to_control_connection(self): return not self._has_usable_node_pool() def _borrow_control_connection(self, connection): - with connection.lock: - if connection.in_flight >= connection.max_request_id: - raise NoConnectionsAvailable("All request IDs are currently in use") - connection.in_flight += 1 - return connection.get_request_id() + control_connection = self.session.cluster.control_connection + with control_connection._application_query_lock: + with connection.lock: + if connection.in_flight >= connection.max_request_id: + raise NoConnectionsAvailable("All request IDs are currently in use") + connection.in_flight += 1 + request_id = connection.get_request_id() + control_connection._application_requests_in_flight += 1 + return request_id - def _release_control_connection_request(self, connection, request_id): - with connection.lock: - connection.in_flight -= 1 - connection.request_ids.append(request_id) - connection._requests.pop(request_id, None) + def _release_control_connection_request(self, connection, request_id, + application_session=None): + control_connection = self.session.cluster.control_connection + with control_connection._application_query_lock: + with connection.lock: + connection.in_flight -= 1 + connection.request_ids.append(request_id) + connection._requests.pop(request_id, None) + control_connection._application_requests_in_flight -= 1 + if application_session is not None: + control_connection._discard_application_session(application_session) def _handle_control_connection_response(self, connection, cb, response): + control_connection = self.session.cluster.control_connection with connection.lock: connection.in_flight -= 1 - cb(response) - - def _query_control_connection(self, message=None, cb=None, connection=None, host=None): - self._control_connection_query_attempted = True + # Keep this request counted while its callback may synchronously hand + # off to another physical fallback request. This closes the zero-in- + # flight window between the driver's USE response and the application + # query it sends next without holding the binding lock during encoding. + try: + cb(response) + finally: + with control_connection._application_query_lock: + control_connection._application_requests_in_flight -= 1 + def _is_keyspace_change_query(self, message=None): + message = self.message if message is None else message + if not isinstance(message, QueryMessage): + return False + query = getattr(message.query, 'query_string', message.query) + if isinstance(query, (bytes, bytearray)): + try: + query = query.decode('utf8') + except UnicodeDecodeError: + return False + return isinstance(query, str) and \ + re.match(r'^(?:\s|(?:--|//)[^\r\n]*(?:\r\n?|\n|$)|/\*(?:[^*]|\*(?!/))*\*/)*USE\b', + query, re.IGNORECASE) is not None + + def _control_connection_failed(self): + self._set_final_exception(NoHostAvailable( + "Unable to complete the operation against any hosts", self._errors)) + + def _set_control_connection_keyspace(self, connection, host, keyspace, + message=None, cb=None, request_id=_NOT_SET, + release_session_on_failure=None): + use_message = QueryMessage( + query='USE %s' % protect_name(keyspace), + consistency_level=ConsistencyLevel.ONE) + + def keyspace_set(response): + if isinstance(response, ResultMessage) and response.kind == RESULT_KIND_SET_KEYSPACE: + connection.keyspace = response.new_keyspace + if self._send_control_connection_message( + message=message, cb=cb, connection=connection, host=host) is None: + self._control_connection_failed() + elif isinstance(response, ErrorMessage): + self._set_final_exception(response.to_exception()) + elif isinstance(response, ConnectionException): + self._errors[host] = response + # Known limitation: this retry goes around the RetryPolicy and + # _query_retries, because both are keyed to the user's statement + # and this is the driver's own USE. It also has no backoff, so a + # flapping control connection re-sends USE until the client-side + # timeout fires instead of failing fast. Routing it through the + # retry machinery shared with the pooled path would be the fix. + self.session.submit(self._retry_task, False, host) + elif isinstance(response, Exception): + self._set_final_exception(response) + else: + self._set_final_exception(ConnectionException( + "Unexpected response while setting the control-connection keyspace: %r" % + (response,), connection.endpoint)) + + # Returns None on failure; send_request() turns that into the final + # NoHostAvailable. Setting it here too would fire every errback twice. + return self._send_control_connection_message( + message=use_message, cb=keyspace_set, connection=connection, + host=host, record_attempt=False, record_size=False, + request_id=request_id, + release_session_on_failure=release_session_on_failure) + + def _send_control_connection_message(self, message=None, cb=None, connection=None, + host=None, record_attempt=True, record_size=True, + request_id=_NOT_SET, + release_session_on_failure=None): if message is None: message = self.message @@ -5229,10 +5436,13 @@ def _query_control_connection(self, message=None, cb=None, connection=None, host host = self.session.cluster.get_control_connection_host() or connection.endpoint self._current_host = host - request_id = None + if request_id is _NOT_SET: + request_id = None request_sent = False + previous_req_id = self._req_id try: - request_id = self._borrow_control_connection(connection) + if request_id is None: + request_id = self._borrow_control_connection(connection) self._connection = connection result_meta = self._bound_result_metadata if cb is None: @@ -5240,12 +5450,22 @@ def _query_control_connection(self, message=None, cb=None, connection=None, host cb = partial(self._handle_control_connection_response, connection, cb) log.debug("No usable node pools; falling back to control connection for host %s", host) - self.request_encoded_size = connection.send_msg(message, request_id, cb=cb, - encoder=self._protocol_handler.encode_message, - decoder=self._protocol_handler.decode_message, - result_metadata=result_meta) + # Record the stream id before sending, not after. The reply can be + # handled re-entrantly - a SET_KEYSPACE reply sends the real message + # from inside this very call - and that nested send has to be the one + # whose id survives in _req_id. Assigning after send_msg() would put + # the already-completed USE id there instead, so a later timeout would + # orphan the wrong stream and leave the real request in _requests. + self._req_id = request_id + encoded_size = connection.send_msg(message, request_id, cb=cb, + encoder=self._protocol_handler.encode_message, + decoder=self._protocol_handler.decode_message, + result_metadata=result_meta) + if record_size: + self.request_encoded_size = encoded_size request_sent = True - self.attempted_hosts.append(host) + if record_attempt: + self.attempted_hosts.append(host) return request_id except NoConnectionsAvailable as exc: log.debug("Control connection is at capacity") @@ -5260,10 +5480,79 @@ def _query_control_connection(self, message=None, cb=None, connection=None, host self._metrics.on_connection_error() finally: if request_id is not None and not request_sent: - self._release_control_connection_request(connection, request_id) + # Only roll back if nothing else claimed _req_id in the meantime, + # so a nested send that did go out keeps its id. + if self._req_id == request_id: + self._req_id = previous_req_id + self._release_control_connection_request( + connection, request_id, release_session_on_failure) return None + def _query_control_connection(self, message=None, cb=None, connection=None, host=None): + self._control_connection_query_attempted = True + control_connection = self.session.cluster.control_connection + if control_connection is None: + self._errors['control connection'] = ConnectionException( + "Control connection is not connected") + return None + + if self._is_keyspace_change_query(message): + self._set_final_exception(InvalidRequest( + "Cannot change keyspace while using control-connection fallback; " + "create a Session with the attached keyspace instead")) + return _NOT_SET + + # Hold this from binding through borrowing a stream. The send itself is + # outside the lock so protocol encoding cannot stall response handling. + with control_connection._application_query_lock: + if connection is None: + connection = control_connection._connection + if connection is None: + self._errors['control connection'] = ConnectionException( + "Control connection is not connected") + return None + + keyspace = self.session.keyspace + new_application_session = \ + self.session not in control_connection._application_sessions + conflict = control_connection._attach_application_session(keyspace, self.session) + if conflict is not None: + self._set_final_exception(InvalidRequest(conflict)) + return _NOT_SET + + if host is None: + host = self.session.cluster.get_control_connection_host() or connection.endpoint + + try: + request_id = self._borrow_control_connection(connection) + except NoConnectionsAvailable as exc: + log.debug("Control connection is at capacity") + self._errors[host] = exc + if new_application_session: + control_connection._discard_application_session(self.session) + return None + except Exception as exc: + log.debug("Error borrowing control connection", exc_info=True) + self._errors[host] = exc + if new_application_session: + control_connection._discard_application_session(self.session) + if self._metrics is not None: + self._metrics.on_connection_error() + return None + + release_session_on_failure = self.session if new_application_session else None + if keyspace is not None and connection.keyspace != keyspace: + return self._set_control_connection_keyspace( + connection, host, keyspace, message=message, cb=cb, + request_id=request_id, + release_session_on_failure=release_session_on_failure) + + return self._send_control_connection_message( + message=message, cb=cb, connection=connection, host=host, + request_id=request_id, + release_session_on_failure=release_session_on_failure) + def _query(self, host, message=None, cb=None): if message is None: message = self.message diff --git a/tests/integration/standard/test_control_connection_query_fallback.py b/tests/integration/standard/test_control_connection_query_fallback.py index b5481f15bc..fed04461d3 100644 --- a/tests/integration/standard/test_control_connection_query_fallback.py +++ b/tests/integration/standard/test_control_connection_query_fallback.py @@ -16,6 +16,7 @@ import pytest +from cassandra import InvalidRequest from cassandra.cluster import ControlConnectionQueryFallback, NoHostAvailable from tests.integration import TestCluster, local, remove_cluster, use_cluster @@ -104,3 +105,92 @@ def test_no_node_pool_fallback_executes_queries_without_creating_pools(self): "SELECT release_version, rpc_address FROM system.local WHERE key='local'").one() assert str(row.rpc_address) == _UNREACHABLE_BROADCAST_RPC_ADDRESS assert row.release_version + + def _bootstrap_keyspaces(self, *keyspaces, tables=()): + bootstrap_cluster = TestCluster( + allow_control_connection_query_fallback=ControlConnectionQueryFallback.SkipPoolCreation, + connect_timeout=1, + ) + try: + setup_session = bootstrap_cluster.connect() + for keyspace in keyspaces: + setup_session.execute("DROP KEYSPACE IF EXISTS {}".format(keyspace)) + setup_session.execute( + "CREATE KEYSPACE {} WITH replication = " + "{{'class': 'NetworkTopologyStrategy', 'replication_factor': 1}}".format(keyspace)) + for table in tables: + setup_session.execute(table) + finally: + bootstrap_cluster.shutdown() + + def test_shared_control_connection_accepts_only_one_session_keyspace(self): + self._bootstrap_keyspaces( + 'fallback_ks_one', 'fallback_ks_two', + tables=("CREATE TABLE fallback_ks_one.items (id int PRIMARY KEY, value text)",)) + + self.cluster = TestCluster( + allow_control_connection_query_fallback=ControlConnectionQueryFallback.SkipPoolCreation, + connect_timeout=1, + ) + session_one = self.cluster.connect('fallback_ks_one') + session_one_peer = self.cluster.connect('fallback_ks_one') + control_connection = self.cluster.control_connection._connection + + assert list(session_one.get_pools()) == [] + assert list(session_one_peer.get_pools()) == [] + with pytest.raises(InvalidRequest, match='already attached'): + self.cluster.connect('fallback_ks_two') + with pytest.raises(InvalidRequest, match='already attached'): + self.cluster.connect() + + insert_one = session_one.execute_async( + "INSERT INTO items (id, value) VALUES (1, 'one')") + insert_two = session_one_peer.execute_async( + "INSERT INTO items (id, value) VALUES (2, 'two')") + insert_one.result() + insert_two.result() + + prepared_one = session_one.prepare( + "INSERT INTO items (id, value) VALUES (?, ?)") + prepared_insert_one = session_one.execute_async(prepared_one, (2, 'prepared-one')) + prepared_insert_one.result() + + select_one = session_one_peer.execute_async( + "SELECT value FROM items WHERE id IN (1, 2)") + + assert {row.value for row in select_one.result()} == {'one', 'prepared-one'} + assert self.cluster.control_connection._connection is control_connection + + def test_shared_control_connection_keyspace_is_reclaimed_after_shutdown(self): + self._bootstrap_keyspaces('fallback_ks_one', 'fallback_ks_two') + + self.cluster = TestCluster( + allow_control_connection_query_fallback=ControlConnectionQueryFallback.SkipPoolCreation, + connect_timeout=1, + ) + session_one = self.cluster.connect('fallback_ks_one') + control_connection = self.cluster.control_connection._connection + + assert list(session_one.get_pools()) == [] + session_one.execute("SELECT key FROM system.local WHERE key='local'") + assert control_connection.keyspace == 'fallback_ks_one' + + # while the binding is held, another keyspace cannot use the fallback + with pytest.raises(InvalidRequest, match='already attached'): + self.cluster.connect('fallback_ks_two') + + session_one.shutdown() + + # the binding is released with its only holder, so it can be taken over + session_two = self.cluster.connect('fallback_ks_two') + session_two.execute("SELECT key FROM system.local WHERE key='local'") + assert control_connection.keyspace == 'fallback_ks_two' + + session_two.shutdown() + + # ...but not by a session without a keyspace: the shared connection is + # still in 'fallback_ks_two' and CQL cannot unset it + with pytest.raises(InvalidRequest, match='cannot be reset to no keyspace'): + self.cluster.connect() + + assert self.cluster.control_connection._connection is control_connection diff --git a/tests/unit/test_cluster.py b/tests/unit/test_cluster.py index 755a3f4888..0aad0cfa69 100644 --- a/tests/unit/test_cluster.py +++ b/tests/unit/test_cluster.py @@ -29,7 +29,7 @@ from cassandra import ConsistencyLevel, DriverException, Timeout, Unavailable, RequestExecutionException, ReadTimeout, WriteTimeout, CoordinationFailure, ReadFailure, WriteFailure, FunctionFailure, AlreadyExists,\ InvalidRequest, Unauthorized, AuthenticationFailed, OperationTimedOut, UnsupportedOperation, RequestValidationException, ConfigurationException, ProtocolVersion from cassandra.cluster import _Scheduler, Session, Cluster, ResultSet, SchemaAgreementScope, ControlConnectionQueryFallback, default_lbp_factory, \ - ExecutionProfile, _ConfigMode, EXEC_PROFILE_DEFAULT + ExecutionProfile, _ConfigMode, EXEC_PROFILE_DEFAULT, _NOT_SET from cassandra.connection import (Connection, ConnectionBusy, ConnectionException, DefaultEndPoint) from cassandra.ssl_session_cache import SSLSessionCache @@ -231,6 +231,11 @@ def test_control_connection_query_fallback_no_node_pool_mode_skips_pool_creation assert session._pools == {} assert session.update_created_pools() == set() + same_keyspace_session = Session(cluster, [host]) + assert same_keyspace_session.keyspace is None + with pytest.raises(InvalidRequest, match='already attached'): + Session(cluster, [host], keyspace='different') + def test_control_connection_query_fallback_fallback_tolerates_empty_initial_pools(self): cluster = Cluster( allow_control_connection_query_fallback=ControlConnectionQueryFallback.Fallback, @@ -246,6 +251,8 @@ def test_control_connection_query_fallback_fallback_tolerates_empty_initial_pool mocked_add_or_renew_pool.assert_called_once_with(host, is_host_addition=False) assert session._initial_connect_futures == {future} assert session._pools == {} + assert set(cluster.control_connection._application_sessions) == set() + assert cluster.control_connection._get_application_keyspace() is _NOT_SET def test_compression_autodisabled_without_libraries(self): with patch.dict('cassandra.cluster.locally_supported_compressions', {}, clear=True): diff --git a/tests/unit/test_response_future.py b/tests/unit/test_response_future.py index d71943ec04..f2572bd29a 100644 --- a/tests/unit/test_response_future.py +++ b/tests/unit/test_response_future.py @@ -12,15 +12,17 @@ # See the License for the specific language governing permissions and # limitations under the License. +import time import unittest from collections import deque -from threading import RLock +from threading import RLock, Thread from unittest.mock import Mock, MagicMock, ANY, patch -from cassandra import ConsistencyLevel, Unavailable, SchemaTargetType, SchemaChangeType, OperationTimedOut -from cassandra.cluster import Session, ResponseFuture, NoHostAvailable, ProtocolVersion, ControlConnectionQueryFallback -from cassandra.connection import Connection, ConnectionException +from cassandra import ConsistencyLevel, InvalidRequest, Unavailable, SchemaTargetType, SchemaChangeType, OperationTimedOut +from cassandra.cluster import (Session, ResponseFuture, NoHostAvailable, ProtocolVersion, + ControlConnection, ControlConnectionQueryFallback, _NOT_SET) +from cassandra.connection import Connection, ConnectionBusy, ConnectionException from cassandra.protocol import (ReadTimeoutErrorMessage, WriteTimeoutErrorMessage, UnavailableErrorMessage, ResultMessage, QueryMessage, ExecuteMessage, @@ -40,8 +42,15 @@ class ResponseFutureTests(unittest.TestCase): def make_basic_session(self): s = Mock(spec=Session) + s.keyspace = None + s.is_shutdown = False s.row_factory = lambda col_names, rows: [(col_names, rows)] s.cluster.allow_control_connection_query_fallback = ControlConnectionQueryFallback.Disabled + s.cluster.control_connection = ControlConnection( + s.cluster, timeout=1, + schema_event_refresh_window=0, + topology_event_refresh_window=0, + status_event_refresh_window=0) return s def make_pool(self): @@ -62,6 +71,7 @@ def make_control_connection(self): connection.orphaned_threshold = 75 connection.orphaned_threshold_reached = False connection.is_control_connection = True + connection.keyspace = None connection.get_request_id.return_value = 7 connection.send_msg.return_value = 128 # These tests exercise control-connection query fallback, not tablet @@ -430,33 +440,402 @@ def test_control_connection_fallback_disabled_by_default(self): with pytest.raises(NoHostAvailable): rf.result() - def test_control_connection_fallback_updates_connection_keyspace(self): + def test_control_connection_fallback_does_not_bind_without_connection(self): session = self.make_basic_session() - session.cluster.allow_control_connection_query_fallback = ControlConnectionQueryFallback.Fallback - session.cluster._default_load_balancing_policy.make_query_plan.return_value = ['ip1'] + session.cluster.allow_control_connection_query_fallback = \ + ControlConnectionQueryFallback.Fallback + session.cluster._default_load_balancing_policy.make_query_plan.return_value = [] session._pools = {} + control_connection = session.cluster.control_connection + control_connection._connection = None - def set_keyspace_for_all_pools(keyspace, callback): - session.keyspace = keyspace - callback({}) + rf = self.make_response_future(session) + assert not rf.send_request() - session._set_keyspace_for_all_pools.side_effect = set_keyspace_for_all_pools + assert not control_connection._application_sessions + assert control_connection._get_application_keyspace() is _NOT_SET + with pytest.raises(NoHostAvailable): + rf.result() + + def test_control_connection_fallback_rejects_use(self): + for query_string in ( + "USE newks", + "-- select another keyspace\nUSE newks", + "-- select another keyspace\rUSE newks", + "// select another keyspace\nUSE newks", + "/* select another keyspace */ USE newks", + b"USE newks", + b"-- select another keyspace\rUSE newks"): + with self.subTest(query_string=query_string): + session = self.make_basic_session() + session.cluster.allow_control_connection_query_fallback = \ + ControlConnectionQueryFallback.Fallback + session.cluster._default_load_balancing_policy.make_query_plan.return_value = ['ip1'] + session._pools = {} + + connection = self.make_control_connection() + session.cluster.control_connection._connection = connection + + query = SimpleStatement(query_string) + rf = ResponseFuture( + session, + QueryMessage(query=query.query_string, consistency_level=ConsistencyLevel.ONE), + query, 1) + assert rf.send_request() + + connection.send_msg.assert_not_called() + assert rf._req_id is None + with pytest.raises(InvalidRequest, match='Cannot change keyspace'): + rf.result() + + # the rejected USE must not have claimed the binding + control_connection = session.cluster.control_connection + assert control_connection._get_application_keyspace() is _NOT_SET + assert not control_connection._application_sessions + + def test_control_connection_fallback_accepts_stream_id_zero(self): + session = self.make_basic_session() + session.cluster.allow_control_connection_query_fallback = \ + ControlConnectionQueryFallback.SkipPoolCreation + session.cluster._default_load_balancing_policy.make_query_plan.return_value = [] + session._pools = {} + session.keyspace = 'ks' connection = self.make_control_connection() - connection.keyspace = 'oldks' + connection.get_request_id.return_value = 0 session.cluster.control_connection._connection = connection + + rf = self.make_response_future(session) + assert rf.send_request() + assert rf._req_id == 0 + assert rf._final_exception is None + assert connection.send_msg.call_args[0][0].query == 'USE ks' + + def test_control_connection_fallback_binds_first_session_keyspace(self): + session1 = self.make_basic_session() + session1.cluster.allow_control_connection_query_fallback = ControlConnectionQueryFallback.SkipPoolCreation + session1.cluster._default_load_balancing_policy.make_query_plan.return_value = [] + session1._pools = {} + session1.keyspace = 'ks1' + + session2 = self.make_basic_session() + session2.cluster = session1.cluster + session2._pools = {} + session2.keyspace = 'ks1' + + connection = self.make_control_connection() + connection.get_request_id.side_effect = [7, 8, 9] + session1.cluster.control_connection._connection = connection control_host = Mock(endpoint=connection.endpoint) - session.cluster.get_control_connection_host.return_value = control_host + session1.cluster.get_control_connection_host.return_value = control_host + + rf1 = self.make_response_future(session1) + rf2 = self.make_response_future(session2) + assert rf1.send_request() + + assert connection.send_msg.call_count == 1 + assert connection.send_msg.call_args_list[0][0][0].query == 'USE ks1' + + connection.send_msg.call_args_list[0][1]['cb']( + Mock(spec=ResultMessage, kind=RESULT_KIND_SET_KEYSPACE, new_keyspace='ks1')) + assert connection.send_msg.call_count == 2 + assert connection.send_msg.call_args_list[1][0][0] is rf1.message + + connection.send_msg.call_args_list[1][1]['cb']( + self.make_mock_response(['value'], [('one',)])) + assert rf2.send_request() + assert connection.send_msg.call_count == 3 + assert connection.send_msg.call_args_list[2][0][0] is rf2.message + connection.send_msg.call_args_list[2][1]['cb']( + self.make_mock_response(['value'], [('two',)])) + + assert rf1.result().one() == (['value'], [('one',)]) + assert rf2.result().one() == (['value'], [('two',)]) + assert connection.keyspace == 'ks1' + assert session1.cluster.control_connection._get_application_keyspace() == 'ks1' + + def test_control_connection_fallback_rejects_different_session_keyspace(self): + for first_keyspace, second_keyspace in ( + ('ks1', 'ks2'), ('ks1', None), (None, 'ks1')): + with self.subTest(first_keyspace=first_keyspace, + second_keyspace=second_keyspace): + session1 = self.make_basic_session() + session1.cluster.allow_control_connection_query_fallback = \ + ControlConnectionQueryFallback.SkipPoolCreation + session1.cluster._default_load_balancing_policy.make_query_plan.return_value = [] + session1._pools = {} + session1.keyspace = first_keyspace + + session2 = self.make_basic_session() + session2.cluster = session1.cluster + session2._pools = {} + session2.keyspace = second_keyspace + + connection = self.make_control_connection() + session1.cluster.control_connection._connection = connection + assert session1.cluster.control_connection._attach_application_session( + first_keyspace, session1) is None + + rf2 = self.make_response_future(session2) + assert rf2.send_request() + + connection.send_msg.assert_not_called() + assert rf2._req_id is None + with pytest.raises(InvalidRequest, match='already attached'): + rf2.result() + + def test_control_connection_fallback_rebinds_same_session_new_keyspace(self): + session1 = self.make_basic_session() + control_connection = session1.cluster.control_connection + connection = self.make_control_connection() + control_connection._connection = connection + + assert control_connection._attach_application_session('ks1', session1) is None + connection.keyspace = 'ks1' + + # the session holding the binding is not in conflict with itself: it + # may rebind to the keyspace it switched to + assert control_connection._attach_application_session('ks2', session1) is None + assert control_connection._get_application_keyspace() == 'ks2' + + session2 = self.make_basic_session() + session2.cluster = session1.cluster + conflict = control_connection._attach_application_session('ks1', session2) + assert conflict is not None and 'already attached' in conflict + + def test_control_connection_fallback_req_id_tracks_real_message(self): + session = self._make_fallback_session(keyspace='ks1') + connection = self.make_control_connection() + connection.get_request_id.side_effect = [7, 8] + session.cluster.control_connection._connection = connection + session.cluster.get_control_connection_host.return_value = Mock(endpoint=connection.endpoint) + + def send_msg(message, request_id, cb=None, **kwargs): + lock = session.cluster.control_connection._application_query_lock + lock_available = [] + + def probe_lock(): + acquired = lock.acquire(False) + lock_available.append(acquired) + if acquired: + lock.release() + + probe = Thread(target=probe_lock) + probe.start() + probe.join() + assert lock_available == [True] + # the SET_KEYSPACE reply lands before send_msg() returns, so the real + # query is sent re-entrantly from inside this very call + if getattr(message, 'query', None) == 'USE ks1': + connection.keyspace = 'ks1' + cb(Mock(spec=ResultMessage, kind=RESULT_KIND_SET_KEYSPACE, + new_keyspace='ks1')) + return 128 + + connection.send_msg.side_effect = send_msg rf = self.make_response_future(session) assert rf.send_request() - result = Mock(spec=ResultMessage, kind=RESULT_KIND_SET_KEYSPACE, new_keyspace='newks') - connection.send_msg.call_args[1]['cb'](result) + # 7 is the USE's stream id, 8 the real query's. _req_id has to be 8: on a + # later timeout it is the id popped from _requests and orphaned, so the + # USE id here would leave the real request in flight and unorphaned. + assert [c[0][1] for c in connection.send_msg.call_args_list] == [7, 8] + assert rf._req_id == 8 + + def test_control_connection_fallback_req_id_restored_when_send_fails(self): + session = self._make_fallback_session(keyspace=None) + connection = self.make_control_connection() + session.cluster.control_connection._connection = connection + session.cluster.get_control_connection_host.return_value = Mock(endpoint=connection.endpoint) + connection.send_msg.side_effect = ConnectionBusy('no streams') + + rf = self.make_response_future(session) + rf._req_id = 42 + # nothing could be sent, so the future ends as NoHostAvailable + assert not rf.send_request() + + # a request that never went out must not leave its id behind for + # _on_timeout() to orphan + assert rf._req_id == 42 + assert not session.cluster.control_connection._application_sessions + assert session.cluster.control_connection._get_application_keyspace() is _NOT_SET + assert session.cluster.control_connection._application_requests_in_flight == 0 + + def test_speculative_execute_honours_expired_deadline_without_attempts(self): + session = self.make_basic_session() + session.cluster._default_load_balancing_policy.make_query_plan.return_value = [] + rf = self.make_response_future(session) + rf.timeout = 1 + rf._start_time = time.time() - 5 + rf.attempted_hosts = [] + rf._on_timeout = Mock() + session.cluster.connection_class.create_timer.reset_mock() + + rf._on_speculative_execute() + + # the PYTHON-836 "no attempt recorded yet" guard must not swallow an + # already-expired client timeout: the driver's own USE is sent with + # record_attempt=False, so a retrying USE would otherwise reschedule + # this callback every 10ms forever and the request would never time out + rf._on_timeout.assert_called_once_with() + session.cluster.connection_class.create_timer.assert_not_called() + + def _make_fallback_session(self, cluster=None, keyspace=None): + session = self.make_basic_session() + if cluster is not None: + session.cluster = cluster + session.cluster.allow_control_connection_query_fallback = \ + ControlConnectionQueryFallback.SkipPoolCreation + session.cluster._default_load_balancing_policy.make_query_plan.return_value = [] + session._pools = {} + session.keyspace = keyspace + return session + + def test_control_connection_fallback_rebinds_after_owner_shutdown(self): + session1 = self._make_fallback_session(keyspace='ks1') + connection = self.make_control_connection() + connection.get_request_id.side_effect = [7, 8, 9] + session1.cluster.control_connection._connection = connection + session1.cluster.get_control_connection_host.return_value = Mock(endpoint=connection.endpoint) + + assert self.make_response_future(session1).send_request() + assert connection.send_msg.call_args_list[0][0][0].query == 'USE ks1' + connection.send_msg.call_args_list[0][1]['cb']( + Mock(spec=ResultMessage, kind=RESULT_KIND_SET_KEYSPACE, new_keyspace='ks1')) + connection.send_msg.call_args_list[1][1]['cb']( + self.make_mock_response(['value'], [('one',)])) - assert connection.keyspace == 'newks' - assert session.keyspace == 'newks' - assert rf.result().current_rows == [] + # the binding is released once its only owner is gone and its request + # has drained + session1.is_shutdown = True + + session2 = self._make_fallback_session(cluster=session1.cluster, keyspace='ks2') + rf2 = self.make_response_future(session2) + assert rf2.send_request() + + assert rf2._final_exception is None + assert connection.send_msg.call_args_list[-1][0][0].query == 'USE ks2' + assert session1.cluster.control_connection._get_application_keyspace() == 'ks2' + + def test_control_connection_fallback_reclaim_ignores_other_control_requests(self): + session1 = self._make_fallback_session(keyspace='ks1') + control_connection = session1.cluster.control_connection + connection = self.make_control_connection() + control_connection._connection = connection + + assert control_connection._attach_application_session('ks1', session1) is None + session1.is_shutdown = True + # Heartbeats and metadata refreshes also contribute to this connection- + # wide counter; they must not keep a fallback binding alive. + connection.in_flight = 1 + + session2 = self._make_fallback_session(cluster=session1.cluster, keyspace='ks2') + assert control_connection._attach_application_session('ks2', session2) is None + assert control_connection._get_application_keyspace() == 'ks2' + + def test_control_connection_fallback_keeps_shutdown_owner_until_requests_drain(self): + session1 = self._make_fallback_session(keyspace='ks1') + connection = self.make_control_connection() + connection.get_request_id.side_effect = [7, 8, 9] + session1.cluster.control_connection._connection = connection + session1.cluster.get_control_connection_host.return_value = Mock(endpoint=connection.endpoint) + + rf1 = self.make_response_future(session1) + assert rf1.send_request() + assert connection.send_msg.call_args_list[0][0][0].query == 'USE ks1' + session1.is_shutdown = True + + # The driver's USE is still in flight, so shutdown cannot release the + # binding to a Session using another keyspace. + session2 = self._make_fallback_session(cluster=session1.cluster, keyspace='ks2') + rf2 = self.make_response_future(session2) + assert rf2.send_request() + with pytest.raises(InvalidRequest, match='already attached'): + rf2.result() + + connection.send_msg.call_args_list[0][1]['cb']( + Mock(spec=ResultMessage, kind=RESULT_KIND_SET_KEYSPACE, new_keyspace='ks1')) + assert connection.send_msg.call_args_list[1][0][0] is rf1.message + + # USE drained, but application query remains in flight and keeps the + # shutdown owner bound. + session3 = self._make_fallback_session(cluster=session1.cluster, keyspace='ks2') + rf3 = self.make_response_future(session3) + assert rf3.send_request() + with pytest.raises(InvalidRequest, match='already attached'): + rf3.result() + + connection.send_msg.call_args_list[1][1]['cb']( + self.make_mock_response(['value'], [('one',)])) + + # Once both physical requests drain, another keyspace can take over. + session4 = self._make_fallback_session(cluster=session1.cluster, keyspace='ks2') + rf4 = self.make_response_future(session4) + assert rf4.send_request() + assert rf4._final_exception is None + assert connection.send_msg.call_args_list[2][0][0].query == 'USE ks2' + assert session1.cluster.control_connection._get_application_keyspace() == 'ks2' + + def test_control_connection_fallback_reclaim_without_keyspace_rejected(self): + session1 = self._make_fallback_session(keyspace='ks1') + connection = self.make_control_connection() + session1.cluster.control_connection._connection = connection + assert session1.cluster.control_connection._attach_application_session( + 'ks1', session1) is None + connection.keyspace = 'ks1' + session1.is_shutdown = True + + # the shared connection is still in 'ks1' and CQL cannot unset it + session2 = self._make_fallback_session(cluster=session1.cluster, keyspace=None) + rf2 = self.make_response_future(session2) + assert rf2.send_request() + + connection.send_msg.assert_not_called() + with pytest.raises(InvalidRequest, match='cannot be reset to no keyspace'): + rf2.result() + + def test_control_connection_fallback_reports_connection_leftover_keyspace(self): + session1 = self._make_fallback_session(keyspace='ks2') + control_connection = session1.cluster.control_connection + connection = self.make_control_connection() + control_connection._connection = connection + assert control_connection._attach_application_session('ks2', session1) is None + # The desired binding can move ahead of the USE that changes the + # physical connection. Report the connection's actual state. + connection.keyspace = 'ks1' + session1.is_shutdown = True + + session2 = self._make_fallback_session(cluster=session1.cluster, keyspace=None) + rf2 = self.make_response_future(session2) + assert rf2.send_request() + + with pytest.raises(InvalidRequest, match="keyspace 'ks1'"): + rf2.result() + + def test_control_connection_fallback_reclaim_without_keyspace_after_reconnect(self): + session1 = self._make_fallback_session(keyspace='ks1') + connection = self.make_control_connection() + session1.cluster.control_connection._connection = connection + assert session1.cluster.control_connection._attach_application_session( + 'ks1', session1) is None + connection.keyspace = 'ks1' + session1.is_shutdown = True + + # a reconnect leaves a fresh connection with no keyspace, so the + # leftover USE state is gone and the binding is freely reclaimable + reconnected = self.make_control_connection() + session1.cluster.control_connection._connection = reconnected + session1.cluster.get_control_connection_host.return_value = \ + Mock(endpoint=reconnected.endpoint) + + session2 = self._make_fallback_session(cluster=session1.cluster, keyspace=None) + rf2 = self.make_response_future(session2) + assert rf2.send_request() + + assert rf2._final_exception is None + assert reconnected.send_msg.call_args_list[0][0][0] is rf2.message + assert session1.cluster.control_connection._get_application_keyspace() is None def test_control_connection_fallback_when_no_usable_pools(self): session = self.make_basic_session() @@ -612,6 +991,107 @@ def test_control_connection_fallback_reprepares_prepared_statement(self): assert connection.in_flight == 0 assert rf.result()[0] == expected_result + def test_control_connection_fallback_reprepare_send_failure_retries(self): + session = self.make_basic_session() + session.cluster.allow_control_connection_query_fallback = \ + ControlConnectionQueryFallback.Fallback + session.cluster._default_load_balancing_policy.make_query_plan.return_value = [] + session._pools = {} + connection = self.make_control_connection() + connection.send_msg.side_effect = ConnectionBusy() + session.cluster.control_connection._connection = connection + host = Mock(endpoint=connection.endpoint) + + rf = self.make_response_future(session) + rf.send_request = Mock() + rf._reprepare(PrepareMessage("SELECT * FROM foo"), host, connection, None) + + rf.send_request.assert_called_once_with() + + def test_control_connection_fallback_rejects_indented_use(self): + session = self.make_basic_session() + session.cluster.allow_control_connection_query_fallback = \ + ControlConnectionQueryFallback.Fallback + session.cluster._default_load_balancing_policy.make_query_plan.return_value = [] + session._pools = {} + + indent = '\n' + ' ' * 26 + rf = self.make_response_future(session) + + # A deeply indented statement must be classified without the leading + # whitespace sending the matcher into exponential backtracking. + indented_use = QueryMessage(query=indent + 'USE newks', + consistency_level=ConsistencyLevel.ONE) + indented_select = QueryMessage(query=indent + 'SELECT * FROM foo', + consistency_level=ConsistencyLevel.ONE) + + start = time.time() + assert rf._is_keyspace_change_query(indented_use) + assert not rf._is_keyspace_change_query(indented_select) + assert time.time() - start < 2 + + def test_control_connection_fallback_use_failure_reports_error_once(self): + session = self.make_basic_session() + session.cluster.allow_control_connection_query_fallback = \ + ControlConnectionQueryFallback.Fallback + session.cluster._default_load_balancing_policy.make_query_plan.return_value = [] + session._pools = {} + session.keyspace = 'ks' + + connection = self.make_control_connection() + connection.keyspace = None + connection.send_msg.side_effect = ConnectionBusy() + session.cluster.control_connection._connection = connection + + rf = self.make_response_future(session) + errback = Mock() + rf.add_errback(errback) + rf.send_request() + + # the USE message could not be sent; the failure is reported exactly once + assert errback.call_count == 1 + assert connection.in_flight == 0 + assert not session.cluster.control_connection._application_sessions + assert session.cluster.control_connection._get_application_keyspace() is _NOT_SET + assert session.cluster.control_connection._application_requests_in_flight == 0 + with pytest.raises(NoHostAvailable): + rf.result() + + def test_control_connection_fallback_reprepare_sets_keyspace_first(self): + session = self.make_basic_session() + session.cluster.allow_control_connection_query_fallback = \ + ControlConnectionQueryFallback.Fallback + session.cluster._default_load_balancing_policy.make_query_plan.return_value = [] + session._pools = {} + session.keyspace = 'ks' + + connection = self.make_control_connection() + connection.keyspace = None + connection.get_request_id.side_effect = [7, 8] + session.cluster.control_connection._connection = connection + host = Mock(endpoint=connection.endpoint) + + rf = self.make_response_future(session) + prepare_message = PrepareMessage("SELECT * FROM foo") + rf._reprepare(prepare_message, host, connection, None) + + # the control connection is switched to the session keyspace first + assert connection.send_msg.call_count == 1 + assert connection.send_msg.call_args_list[0][0][0].query == 'USE ks' + + connection.send_msg.call_args_list[0][1]['cb']( + Mock(spec=ResultMessage, kind=RESULT_KIND_SET_KEYSPACE, new_keyspace='ks')) + + # ...and only then is the PrepareMessage sent, with the reprepare callback + assert connection.send_msg.call_count == 2 + assert connection.send_msg.call_args_list[1][0][0] is prepare_message + assert connection.send_msg.call_args_list[1][0][1] == 8 + + prepared_response = Mock(spec=ResultMessage, kind=RESULT_KIND_PREPARED) + connection.send_msg.call_args_list[1][1]['cb'](prepared_response) + session.submit.assert_called_once_with( + rf._execute_after_prepare, host, connection, None, prepared_response) + def test_control_connection_fallback_not_used_when_pool_can_serve(self): session = self.make_basic_session() session.cluster.allow_control_connection_query_fallback = ControlConnectionQueryFallback.Fallback From a8b9f365753466a54785bf9052ae1e574819681d Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Mon, 21 Sep 2026 23:18:21 -0400 Subject: [PATCH 182/200] control-connection: harden fallback binding lifecycle Release logical fallback accounting when a request times out while retaining an orphan barrier until its late response can no longer change the physical keyspace. Prevent active self-rebinding and validate reclaimed bindings against the connection's actual keyspace. Limit CQL USE detection to CQL statements so valid graph queries are not rejected. --- cassandra/cluster.py | 97 +++++++++++++++++++--- tests/unit/test_response_future.py | 127 +++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+), 13 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 181fdf47b8..02b4cd4d58 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -4030,6 +4030,7 @@ def __init__(self, cluster, timeout, self._application_keyspace = _NOT_SET self._application_sessions = WeakSet() self._application_requests_in_flight = 0 + self._application_orphaned_requests = set() def connect(self): if self._is_shutdown: @@ -4067,14 +4068,26 @@ def _attach_application_session(self, keyspace, session): with self._application_query_lock: self._prune_application_sessions() - # A Session already holding the binding is not in conflict with - # itself: it may rebind to the keyspace it switched to. - if any(other is not session for other in self._application_sessions): - if self._application_keyspace != keyspace: - return ("Control-connection fallback is already attached to " - "keyspace %r; cannot use it from a Session using " - "keyspace %r" % (self._application_keyspace, keyspace)) - elif keyspace is None: + other_sessions = any( + other is not session for other in self._application_sessions) + current_connection_has_orphans = \ + self._current_connection_has_application_orphans() + binding_is_busy = self._application_requests_in_flight or \ + current_connection_has_orphans + + # Even the Session that owns the binding cannot change it while a + # request is active. A recovered node pool may have changed the + # Session keyspace while an earlier control-connection USE or query + # is still outstanding, and allowing the next fallback request to + # rebind would put both keyspaces on the shared connection at once. + if self._application_keyspace is not _NOT_SET and \ + self._application_keyspace != keyspace and \ + (other_sessions or binding_is_busy): + return ("Control-connection fallback is already attached to " + "keyspace %r; cannot use it from a Session using " + "keyspace %r" % (self._application_keyspace, keyspace)) + + if keyspace is None: # Reclaiming from a gone Session cannot undo the USE it left on # the shared connection: CQL has no way back to "no keyspace". leftover = self._leftover_application_keyspace() @@ -4093,7 +4106,8 @@ def _discard_application_session(self, session): with self._application_query_lock: self._application_sessions.discard(session) if not self._application_sessions and \ - not self._application_requests_in_flight: + not self._application_requests_in_flight and \ + not self._current_connection_has_application_orphans(): self._application_keyspace = _NOT_SET def _prune_application_sessions(self): @@ -4106,8 +4120,6 @@ def _prune_application_sessions(self): def _leftover_application_keyspace(self): """The keyspace a released binding left the shared connection in, if any.""" - if self._application_keyspace is _NOT_SET or self._application_keyspace is None: - return None connection = self._connection # A reconnect replaces the connection, and a fresh one starts out with # no keyspace, so the leftover USE state went away with the old one. @@ -4115,10 +4127,27 @@ def _leftover_application_keyspace(self): return None return connection.keyspace + def _current_connection_has_application_orphans(self): + return any( + connection is self._connection + for connection, _ in self._application_orphaned_requests) + def _get_application_keyspace(self): with self._application_query_lock: return self._application_keyspace + def _handle_orphaned_application_response(self, connection, request_id, + response): + """Retire a timed-out fallback stream without resuming its request.""" + try: + if isinstance(response, ResultMessage) and \ + response.kind == RESULT_KIND_SET_KEYSPACE: + connection.keyspace = response.new_keyspace + finally: + with self._application_query_lock: + self._application_orphaned_requests.discard( + (connection, request_id)) + def _try_connect_to_hosts(self): errors = {} @@ -5181,8 +5210,14 @@ def _on_timeout(self, _attempts=0): conn_in_flight = None if self._connection is not None: + orphaned_control_request = \ + self._connection.is_control_connection and \ + self._control_connection_query_attempted and \ + self._orphan_control_connection_request( + self._connection, self._req_id) try: - self._connection._requests.pop(self._req_id) + if not orphaned_control_request: + self._connection._requests.pop(self._req_id) # PYTHON-1044 # This request might have been removed from the connection after the latter was defunct by heartbeat. # We should still raise OperationTimedOut to reject the future so that the main event thread will not @@ -5211,7 +5246,8 @@ def _on_timeout(self, _attempts=0): self._connection.orphaned_threshold_reached = True pool.return_connection(self._connection, stream_was_orphaned=True) - elif self._connection.is_control_connection: + elif self._connection.is_control_connection and \ + not orphaned_control_request: with self._connection.lock: self._connection.orphaned_request_ids.add(self._req_id) if len(self._connection.orphaned_request_ids) >= self._connection.orphaned_threshold: @@ -5361,8 +5397,43 @@ def _handle_control_connection_response(self, connection, cb, response): with control_connection._application_query_lock: control_connection._application_requests_in_flight -= 1 + def _orphan_control_connection_request(self, connection, request_id): + """Detach a timed-out fallback request while retaining a safe barrier. + + The normal response callback owns the application in-flight count. A + timeout removes that callback, so replace it with a cleanup-only + callback and release the count here. The orphan remains a binding + barrier until its late response arrives because a timed-out ``USE`` can + still change the physical connection keyspace. + """ + control_connection = self.session.cluster.control_connection + with control_connection._application_query_lock: + with connection.lock: + try: + _, decoder, result_metadata = connection._requests[request_id] + except KeyError: + return False + + orphan_key = (connection, request_id) + if orphan_key in control_connection._application_orphaned_requests: + return True + callback = partial( + control_connection._handle_orphaned_application_response, + connection, request_id) + connection._requests[request_id] = \ + (callback, decoder, result_metadata) + connection.orphaned_request_ids.add(request_id) + if len(connection.orphaned_request_ids) >= connection.orphaned_threshold: + connection.orphaned_threshold_reached = True + + control_connection._application_requests_in_flight -= 1 + control_connection._application_orphaned_requests.add(orphan_key) + return True + def _is_keyspace_change_query(self, message=None): message = self.message if message is None else message + if isinstance(self.query, GraphStatement): + return False if not isinstance(message, QueryMessage): return False query = getattr(message.query, 'query_string', message.query) diff --git a/tests/unit/test_response_future.py b/tests/unit/test_response_future.py index f2572bd29a..e1929f6cba 100644 --- a/tests/unit/test_response_future.py +++ b/tests/unit/test_response_future.py @@ -23,6 +23,7 @@ from cassandra.cluster import (Session, ResponseFuture, NoHostAvailable, ProtocolVersion, ControlConnection, ControlConnectionQueryFallback, _NOT_SET) from cassandra.connection import Connection, ConnectionBusy, ConnectionException +from cassandra.datastax.graph import SimpleGraphStatement from cassandra.protocol import (ReadTimeoutErrorMessage, WriteTimeoutErrorMessage, UnavailableErrorMessage, ResultMessage, QueryMessage, ExecuteMessage, @@ -603,6 +604,37 @@ def test_control_connection_fallback_rebinds_same_session_new_keyspace(self): conflict = control_connection._attach_application_session('ks1', session2) assert conflict is not None and 'already attached' in conflict + def test_control_connection_fallback_blocks_self_rebind_while_request_active(self): + session = self._make_fallback_session(keyspace='ks1') + control_connection = session.cluster.control_connection + connection = self.make_control_connection() + connection.keyspace = 'ks1' + connection.get_request_id.side_effect = [7, 8] + control_connection._connection = connection + session.cluster.get_control_connection_host.return_value = \ + Mock(endpoint=connection.endpoint) + + rf1 = self.make_response_future(session) + assert rf1.send_request() + assert control_connection._application_requests_in_flight == 1 + + # Simulate set_keyspace() succeeding through a recovered node pool + # while the old-keyspace fallback query is still outstanding. + session.keyspace = 'ks2' + rf2 = self.make_response_future(session) + assert rf2.send_request() + with pytest.raises(InvalidRequest, match='already attached'): + rf2.result() + assert connection.send_msg.call_count == 1 + + connection.send_msg.call_args_list[0][1]['cb']( + self.make_mock_response(['value'], [('one',)])) + + # Once the old request drains, the sole owner may safely rebind. + rf3 = self.make_response_future(session) + assert rf3.send_request() + assert connection.send_msg.call_args_list[1][0][0].query == 'USE ks2' + def test_control_connection_fallback_req_id_tracks_real_message(self): session = self._make_fallback_session(keyspace='ks1') connection = self.make_control_connection() @@ -813,6 +845,35 @@ def test_control_connection_fallback_reports_connection_leftover_keyspace(self): with pytest.raises(InvalidRequest, match="keyspace 'ks1'"): rf2.result() + def test_control_connection_fallback_send_failure_preserves_physical_keyspace(self): + session1 = self._make_fallback_session(keyspace='ks1') + control_connection = session1.cluster.control_connection + connection = self.make_control_connection() + connection.keyspace = 'ks1' + connection.send_msg.side_effect = ConnectionBusy('no streams') + control_connection._connection = connection + assert control_connection._attach_application_session( + 'ks1', session1) is None + session1.is_shutdown = True + + # The new owner is provisionally attached, but its USE ks2 cannot be + # sent and the logical attachment is discarded. + session2 = self._make_fallback_session( + cluster=session1.cluster, keyspace='ks2') + rf2 = self.make_response_future(session2) + assert not rf2.send_request() + assert control_connection._get_application_keyspace() is _NOT_SET + assert connection.keyspace == 'ks1' + + # The discarded logical binding must not hide the physical ks1 state. + session3 = self._make_fallback_session( + cluster=session1.cluster, keyspace=None) + rf3 = self.make_response_future(session3) + assert rf3.send_request() + with pytest.raises(InvalidRequest, match="keyspace 'ks1'"): + rf3.result() + assert connection.send_msg.call_count == 1 + def test_control_connection_fallback_reclaim_without_keyspace_after_reconnect(self): session1 = self._make_fallback_session(keyspace='ks1') connection = self.make_control_connection() @@ -939,6 +1000,7 @@ def test_control_connection_fallback_reprepares_prepared_statement(self): session = self.make_basic_session() session.cluster.allow_control_connection_query_fallback = ControlConnectionQueryFallback.Fallback session.cluster.protocol_version = ProtocolVersion.V4 + session.keyspace = "FooKeyspace" session.cluster._default_load_balancing_policy.make_query_plan.return_value = ['ip1'] session._pools = {} session.submit.side_effect = lambda fn, *args, **kwargs: fn(*args, **kwargs) @@ -1030,6 +1092,17 @@ def test_control_connection_fallback_rejects_indented_use(self): assert not rf._is_keyspace_change_query(indented_select) assert time.time() - start < 2 + def test_control_connection_fallback_does_not_treat_graph_use_as_cql(self): + session = self.make_basic_session() + session.cluster._default_load_balancing_policy.make_query_plan.return_value = [] + graph_query = SimpleGraphStatement( + 'use(TimeCategory) { g.V().count() }') + message = QueryMessage(query=graph_query.query, + consistency_level=ConsistencyLevel.ONE) + rf = ResponseFuture(session, message, graph_query, 1) + + assert not rf._is_keyspace_change_query() + def test_control_connection_fallback_use_failure_reports_error_once(self): session = self.make_basic_session() session.cluster.allow_control_connection_query_fallback = \ @@ -1129,9 +1202,63 @@ def send_msg(message, request_id, cb, **kwargs): assert 7 in connection.orphaned_request_ids assert connection.in_flight == 1 + assert session.cluster.control_connection._application_requests_in_flight == 0 + assert (connection, 7) in \ + session.cluster.control_connection._application_orphaned_requests with pytest.raises(OperationTimedOut): rf.result() + def test_control_connection_fallback_timeout_barrier_ends_on_late_use(self): + session1 = self._make_fallback_session(keyspace='ks1') + control_connection = session1.cluster.control_connection + connection = self.make_control_connection() + connection.get_request_id.side_effect = [7, 8] + control_connection._connection = connection + session1.cluster.get_control_connection_host.return_value = \ + Mock(endpoint=connection.endpoint) + + def send_msg(message, request_id, cb, **kwargs): + connection._requests[request_id] = \ + (cb, kwargs.get('decoder'), kwargs.get('result_metadata')) + return 128 + + connection.send_msg.side_effect = send_msg + + rf1 = self.make_response_future(session1) + assert rf1.send_request() + assert connection.send_msg.call_args_list[0][0][0].query == 'USE ks1' + rf1._on_timeout() + session1.is_shutdown = True + + # Active accounting is released at timeout, but the orphaned USE still + # prevents a different keyspace from sharing the physical connection. + assert control_connection._application_requests_in_flight == 0 + session2 = self._make_fallback_session( + cluster=session1.cluster, keyspace='ks2') + rf2 = self.make_response_future(session2) + assert rf2.send_request() + with pytest.raises(InvalidRequest, match='already attached'): + rf2.result() + + # Process the late SET_KEYSPACE response through the cleanup-only + # callback installed by the timeout. It must not send rf1's query. + late_cb, _, _ = connection._requests.pop(7) + with connection.lock: + connection.in_flight -= 1 + connection.orphaned_request_ids.remove(7) + late_cb(Mock(spec=ResultMessage, kind=RESULT_KIND_SET_KEYSPACE, + new_keyspace='ks1')) + assert connection.send_msg.call_count == 1 + assert not control_connection._application_orphaned_requests + assert connection.keyspace == 'ks1' + + # With the late USE retired, a new owner can safely switch keyspaces. + session3 = self._make_fallback_session( + cluster=session1.cluster, keyspace='ks2') + rf3 = self.make_response_future(session3) + assert rf3.send_request() + assert connection.send_msg.call_args_list[1][0][0].query == 'USE ks2' + def test_control_connection_fallback_timeout_without_metadata_host_uses_connection_endpoint(self): session = self.make_basic_session() session.cluster.allow_control_connection_query_fallback = ControlConnectionQueryFallback.Fallback From 923a6f6ddd0cdfdfe698c1a4d8d3f2b9cceedefc Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Tue, 22 Sep 2026 08:09:22 -0400 Subject: [PATCH 183/200] control-connection: preserve concurrent fallback ownership Keep provisional session claims until all concurrent initial sends resolve, so a failed first send cannot release ownership claimed by another request. Cover the overlap where one send fails while another is pending, then verify different keyspaces remain rejected and the original session can continue. --- cassandra/cluster.py | 65 ++++++++++++++++++++------ tests/unit/test_response_future.py | 75 +++++++++++++++++++++++++++++- 2 files changed, 124 insertions(+), 16 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 02b4cd4d58..a52d2af8f4 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -4029,6 +4029,10 @@ def __init__(self, cluster, timeout, self._application_query_lock = RLock() self._application_keyspace = _NOT_SET self._application_sessions = WeakSet() + # A new Session is provisional until one of its fallback sends + # succeeds. Count concurrent attempts so one failure cannot discard an + # attachment another attempt is about to confirm. + self._application_session_claims = {} self._application_requests_in_flight = 0 self._application_orphaned_requests = set() @@ -4104,12 +4108,38 @@ def _attach_application_session(self, keyspace, session): def _discard_application_session(self, session): """Undo an attachment when its first fallback request was not sent.""" with self._application_query_lock: + self._application_session_claims.pop(session, None) self._application_sessions.discard(session) if not self._application_sessions and \ not self._application_requests_in_flight and \ not self._current_connection_has_application_orphans(): self._application_keyspace = _NOT_SET + def _begin_application_session_claim(self, session, new_session): + """Track a send that can confirm a provisional Session attachment.""" + with self._application_query_lock: + if new_session: + self._application_session_claims[session] = 0 + if session not in self._application_session_claims: + return None + self._application_session_claims[session] += 1 + return session + + def _finish_application_session_claim(self, session, request_sent): + """Confirm an attachment, or discard it after all initial sends fail.""" + if session is None: + return + with self._application_query_lock: + claims = self._application_session_claims.get(session) + if claims is None: + return + if request_sent: + del self._application_session_claims[session] + elif claims > 1: + self._application_session_claims[session] = claims - 1 + else: + self._discard_application_session(session) + def _prune_application_sessions(self): """Drop shut-down owners after shared-connection requests have drained.""" if self._application_requests_in_flight: @@ -5372,7 +5402,7 @@ def _borrow_control_connection(self, connection): return request_id def _release_control_connection_request(self, connection, request_id, - application_session=None): + provisional_session=None): control_connection = self.session.cluster.control_connection with control_connection._application_query_lock: with connection.lock: @@ -5380,8 +5410,8 @@ def _release_control_connection_request(self, connection, request_id, connection.request_ids.append(request_id) connection._requests.pop(request_id, None) control_connection._application_requests_in_flight -= 1 - if application_session is not None: - control_connection._discard_application_session(application_session) + control_connection._finish_application_session_claim( + provisional_session, False) def _handle_control_connection_response(self, connection, cb, response): control_connection = self.session.cluster.control_connection @@ -5452,7 +5482,7 @@ def _control_connection_failed(self): def _set_control_connection_keyspace(self, connection, host, keyspace, message=None, cb=None, request_id=_NOT_SET, - release_session_on_failure=None): + provisional_session=None): use_message = QueryMessage( query='USE %s' % protect_name(keyspace), consistency_level=ConsistencyLevel.ONE) @@ -5487,12 +5517,12 @@ def keyspace_set(response): message=use_message, cb=keyspace_set, connection=connection, host=host, record_attempt=False, record_size=False, request_id=request_id, - release_session_on_failure=release_session_on_failure) + provisional_session=provisional_session) def _send_control_connection_message(self, message=None, cb=None, connection=None, host=None, record_attempt=True, record_size=True, request_id=_NOT_SET, - release_session_on_failure=None): + provisional_session=None): if message is None: message = self.message @@ -5532,9 +5562,12 @@ def _send_control_connection_message(self, message=None, cb=None, connection=Non encoder=self._protocol_handler.encode_message, decoder=self._protocol_handler.decode_message, result_metadata=result_meta) + request_sent = True + control_connection = self.session.cluster.control_connection + control_connection._finish_application_session_claim( + provisional_session, True) if record_size: self.request_encoded_size = encoded_size - request_sent = True if record_attempt: self.attempted_hosts.append(host) return request_id @@ -5556,7 +5589,7 @@ def _send_control_connection_message(self, message=None, cb=None, connection=Non if self._req_id == request_id: self._req_id = previous_req_id self._release_control_connection_request( - connection, request_id, release_session_on_failure) + connection, request_id, provisional_session) return None @@ -5591,6 +5624,9 @@ def _query_control_connection(self, message=None, cb=None, connection=None, host if conflict is not None: self._set_final_exception(InvalidRequest(conflict)) return _NOT_SET + provisional_session = \ + control_connection._begin_application_session_claim( + self.session, new_application_session) if host is None: host = self.session.cluster.get_control_connection_host() or connection.endpoint @@ -5600,29 +5636,28 @@ def _query_control_connection(self, message=None, cb=None, connection=None, host except NoConnectionsAvailable as exc: log.debug("Control connection is at capacity") self._errors[host] = exc - if new_application_session: - control_connection._discard_application_session(self.session) + control_connection._finish_application_session_claim( + provisional_session, False) return None except Exception as exc: log.debug("Error borrowing control connection", exc_info=True) self._errors[host] = exc - if new_application_session: - control_connection._discard_application_session(self.session) + control_connection._finish_application_session_claim( + provisional_session, False) if self._metrics is not None: self._metrics.on_connection_error() return None - release_session_on_failure = self.session if new_application_session else None if keyspace is not None and connection.keyspace != keyspace: return self._set_control_connection_keyspace( connection, host, keyspace, message=message, cb=cb, request_id=request_id, - release_session_on_failure=release_session_on_failure) + provisional_session=provisional_session) return self._send_control_connection_message( message=message, cb=cb, connection=connection, host=host, request_id=request_id, - release_session_on_failure=release_session_on_failure) + provisional_session=provisional_session) def _query(self, host, message=None, cb=None): if message is None: diff --git a/tests/unit/test_response_future.py b/tests/unit/test_response_future.py index e1929f6cba..87bee8f8ca 100644 --- a/tests/unit/test_response_future.py +++ b/tests/unit/test_response_future.py @@ -16,7 +16,7 @@ import unittest from collections import deque -from threading import RLock, Thread +from threading import Event, RLock, Thread from unittest.mock import Mock, MagicMock, ANY, patch from cassandra import ConsistencyLevel, InvalidRequest, Unavailable, SchemaTargetType, SchemaChangeType, OperationTimedOut @@ -694,6 +694,79 @@ def test_control_connection_fallback_req_id_restored_when_send_fails(self): assert session.cluster.control_connection._get_application_keyspace() is _NOT_SET assert session.cluster.control_connection._application_requests_in_flight == 0 + def test_control_connection_fallback_concurrent_send_preserves_session(self): + session = self._make_fallback_session(keyspace='ks1') + session.cluster.allow_control_connection_query_fallback = \ + ControlConnectionQueryFallback.Fallback + control_connection = session.cluster.control_connection + connection = self.make_control_connection() + connection.keyspace = 'ks1' + connection.get_request_id.side_effect = [7, 8, 9] + control_connection._connection = connection + session.cluster.get_control_connection_host.return_value = \ + Mock(endpoint=connection.endpoint) + + first_send_started = Event() + second_send_started = Event() + release_first_send = Event() + release_second_send = Event() + + def send_msg(message, request_id, **kwargs): + if request_id == 7: + first_send_started.set() + assert release_first_send.wait(5) + raise ConnectionBusy('no streams') + if request_id == 8: + second_send_started.set() + assert release_second_send.wait(5) + return 128 + + connection.send_msg.side_effect = send_msg + rf1 = self.make_response_future(session) + first_result = [] + first_thread = Thread(target=lambda: first_result.append(rf1.send_request())) + first_thread.start() + assert first_send_started.wait(5) + + rf2 = self.make_response_future(session) + second_result = [] + second_thread = Thread( + target=lambda: second_result.append(rf2.send_request())) + second_thread.start() + assert second_send_started.wait(5) + + # Even before the second send returns successfully, its pending claim + # prevents the first failure from discarding their shared Session. + release_first_send.set() + first_thread.join(5) + assert not first_thread.is_alive() + assert first_result == [False] + assert session in control_connection._application_sessions + + release_second_send.set() + second_thread.join(5) + assert not second_thread.is_alive() + assert second_result == [True] + + # The successful concurrent request claimed the provisional binding. + # Finishing the failed first send must not release its live Session. + connection.send_msg.call_args_list[1][1]['cb']( + self.make_mock_response(['value'], [('two',)])) + assert control_connection._application_requests_in_flight == 0 + assert session in control_connection._application_sessions + assert control_connection._get_application_keyspace() == 'ks1' + + other_session = self._make_fallback_session( + cluster=session.cluster, keyspace='ks2') + other_rf = self.make_response_future(other_session) + assert other_rf.send_request() + with pytest.raises(InvalidRequest, match='already attached'): + other_rf.result() + + rf3 = self.make_response_future(session) + assert rf3.send_request() + assert connection.send_msg.call_args_list[2][0][0] is rf3.message + def test_speculative_execute_honours_expired_deadline_without_attempts(self): session = self.make_basic_session() session.cluster._default_load_balancing_policy.make_query_plan.return_value = [] From 4880545ff0154a7db6f01b71cb4e41880615950e Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Tue, 22 Sep 2026 11:40:03 -0400 Subject: [PATCH 184/200] cluster: protect timeout stream ownership Record pooled request IDs before sending so a speculative timeout can detach the callback for the stream actually in flight. Track active control-connection fallback callbacks and verify their identity before orphaning a stream. This keeps recycled control request IDs and fallback accounting untouched after the original response has completed. --- cassandra/cluster.py | 97 ++++++++++++++++++++++++------ tests/unit/test_response_future.py | 97 ++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+), 20 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index a52d2af8f4..bcc4fd062d 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -5198,6 +5198,13 @@ def __init__(self, session, message, query, timeout, metrics=None, prepared_stat self._errors = {} self._callbacks = [] self._errbacks = [] + # Maps fallback (connection, request id) pairs to the callback this + # future installed. Request ids may be reused as soon as a response + # callback returns, while the future can remain pending for a retry, + # reprepare, or schema agreement. Keep the callback identity as well as + # the id so a later timeout cannot detach an unrelated request that + # reused the same stream. + self._control_connection_requests = {} self.attempted_hosts = [] self._start_timer() self._continuous_paging_state = continuous_paging_state @@ -5240,13 +5247,18 @@ def _on_timeout(self, _attempts=0): conn_in_flight = None if self._connection is not None: - orphaned_control_request = \ + control_connection_request = \ self._connection.is_control_connection and \ - self._control_connection_query_attempted and \ + self._control_connection_query_attempted + if control_connection_request: self._orphan_control_connection_request( self._connection, self._req_id) try: - if not orphaned_control_request: + # A completed fallback stream may already have been reused by + # control traffic while this future waits for follow-up work. + # _orphan_control_connection_request() verifies ownership; if + # it does not own the stream, leave the current request alone. + if not control_connection_request: self._connection._requests.pop(self._req_id) # PYTHON-1044 # This request might have been removed from the connection after the latter was defunct by heartbeat. @@ -5263,7 +5275,11 @@ def _on_timeout(self, _attempts=0): # Capture connection stats before pool.return_connection() can alter state conn_in_flight = self._connection.in_flight - pool = self.session._pools.get(self._current_host) + # Fallback requests never belong to a Session pool. A pool may + # recover while the future waits for retry/schema work, but the + # completed fallback stream must not be returned through that pool. + pool = None if control_connection_request else \ + self.session._pools.get(self._current_host) if pool and not pool.is_shutdown: # Do not return the stream ID to the pool yet. We cannot reuse it # because the node might still be processing the query and will @@ -5277,7 +5293,7 @@ def _on_timeout(self, _attempts=0): pool.return_connection(self._connection, stream_was_orphaned=True) elif self._connection.is_control_connection and \ - not orphaned_control_request: + not control_connection_request: with self._connection.lock: self._connection.orphaned_request_ids.add(self._req_id) if len(self._connection.orphaned_request_ids) >= self._connection.orphaned_threshold: @@ -5405,15 +5421,31 @@ def _release_control_connection_request(self, connection, request_id, provisional_session=None): control_connection = self.session.cluster.control_connection with control_connection._application_query_lock: - with connection.lock: - connection.in_flight -= 1 - connection.request_ids.append(request_id) - connection._requests.pop(request_id, None) - control_connection._application_requests_in_flight -= 1 + request_key = (connection, request_id) + active_request = self._control_connection_requests.pop( + request_key, None) + orphaned_request = request_key in \ + control_connection._application_orphaned_requests + if orphaned_request: + control_connection._application_orphaned_requests.discard( + request_key) + + # A timeout may have taken ownership while send_msg() was still + # encoding and then send_msg() may fail before pushing any bytes. + # Retire either form of ownership exactly once in that case. + if active_request is not None or orphaned_request: + with connection.lock: + connection.in_flight -= 1 + connection.request_ids.append(request_id) + connection._requests.pop(request_id, None) + connection.orphaned_request_ids.discard(request_id) + if active_request is not None: + control_connection._application_requests_in_flight -= 1 control_connection._finish_application_session_claim( provisional_session, False) - def _handle_control_connection_response(self, connection, cb, response): + def _handle_control_connection_response(self, connection, request_id, cb, + response): control_connection = self.session.cluster.control_connection with connection.lock: connection.in_flight -= 1 @@ -5425,7 +5457,10 @@ def _handle_control_connection_response(self, connection, cb, response): cb(response) finally: with control_connection._application_query_lock: - control_connection._application_requests_in_flight -= 1 + request_key = (connection, request_id) + if self._control_connection_requests.pop( + request_key, None) is not None: + control_connection._application_requests_in_flight -= 1 def _orphan_control_connection_request(self, connection, request_id): """Detach a timed-out fallback request while retaining a safe barrier. @@ -5439,14 +5474,19 @@ def _orphan_control_connection_request(self, connection, request_id): control_connection = self.session.cluster.control_connection with control_connection._application_query_lock: with connection.lock: + orphan_key = (connection, request_id) + expected_callback = self._control_connection_requests.get( + orphan_key) + if expected_callback is None: + return False try: - _, decoder, result_metadata = connection._requests[request_id] + callback, decoder, result_metadata = \ + connection._requests[request_id] except KeyError: return False - orphan_key = (connection, request_id) - if orphan_key in control_connection._application_orphaned_requests: - return True + if callback is not expected_callback: + return False callback = partial( control_connection._handle_orphaned_application_response, connection, request_id) @@ -5456,6 +5496,7 @@ def _orphan_control_connection_request(self, connection, request_id): if len(connection.orphaned_request_ids) >= connection.orphaned_threshold: connection.orphaned_threshold_reached = True + self._control_connection_requests.pop(orphan_key, None) control_connection._application_requests_in_flight -= 1 control_connection._application_orphaned_requests.add(orphan_key) return True @@ -5544,11 +5585,17 @@ def _send_control_connection_message(self, message=None, cb=None, connection=Non try: if request_id is None: request_id = self._borrow_control_connection(connection) - self._connection = connection result_meta = self._bound_result_metadata if cb is None: cb = partial(self._set_result, host, connection, None) - cb = partial(self._handle_control_connection_response, connection, cb) + cb = partial(self._handle_control_connection_response, connection, + request_id, cb) + + control_connection = self.session.cluster.control_connection + with control_connection._application_query_lock: + self._control_connection_requests[(connection, request_id)] = cb + self._connection = connection + self._req_id = request_id log.debug("No usable node pools; falling back to control connection for host %s", host) # Record the stream id before sending, not after. The reply can be @@ -5557,13 +5604,11 @@ def _send_control_connection_message(self, message=None, cb=None, connection=Non # whose id survives in _req_id. Assigning after send_msg() would put # the already-completed USE id there instead, so a later timeout would # orphan the wrong stream and leave the real request in _requests. - self._req_id = request_id encoded_size = connection.send_msg(message, request_id, cb=cb, encoder=self._protocol_handler.encode_message, decoder=self._protocol_handler.decode_message, result_metadata=result_meta) request_sent = True - control_connection = self.session.cluster.control_connection control_connection._finish_application_session_claim( provisional_session, True) if record_size: @@ -5676,6 +5721,9 @@ def _query(self, host, message=None, cb=None): self._current_host = host connection = None + previous_req_id = self._req_id + request_id = None + request_sent = False try: # TODO get connectTimeout from cluster settings if self.query: @@ -5693,10 +5741,15 @@ def _query(self, host, message=None, cb=None): if cb is None: cb = partial(self._set_result, host, connection, pool) + # Record the stream before send_msg() starts. Encoding or pushing a + # message can overlap the deadline timer; the timeout must be able + # to detach the callback send_msg() has installed for this stream. + self._req_id = request_id self.request_encoded_size = connection.send_msg(message, request_id, cb=cb, encoder=self._protocol_handler.encode_message, decoder=self._protocol_handler.decode_message, result_metadata=result_meta) + request_sent = True self.attempted_hosts.append(host) return request_id except NoConnectionsAvailable as exc: @@ -5712,6 +5765,10 @@ def _query(self, host, message=None, cb=None): self._metrics.on_connection_error() if connection: pool.return_connection(connection) + finally: + if request_id is not None and not request_sent and \ + self._req_id == request_id: + self._req_id = previous_req_id return None diff --git a/tests/unit/test_response_future.py b/tests/unit/test_response_future.py index 87bee8f8ca..1515143bd5 100644 --- a/tests/unit/test_response_future.py +++ b/tests/unit/test_response_future.py @@ -786,6 +786,58 @@ def test_speculative_execute_honours_expired_deadline_without_attempts(self): rf._on_timeout.assert_called_once_with() session.cluster.connection_class.create_timer.assert_not_called() + def test_speculative_timeout_during_first_send_orphans_real_stream(self): + session = self.make_basic_session() + host = Mock(endpoint='ip1') + session.cluster._default_load_balancing_policy.make_query_plan.return_value = [host] + + pool = Mock(is_shutdown=False) + connection = Mock(spec=Connection) + connection.lock = RLock() + connection._requests = {} + connection.in_flight = 1 + connection.is_control_connection = False + connection.orphaned_request_ids = set() + connection.orphaned_threshold = 75 + connection.orphaned_threshold_reached = False + pool.borrow_connection.return_value = (connection, 11) + session._pools = {host: pool} + + send_started = Event() + release_send = Event() + + def send_msg(message, request_id, cb, **kwargs): + connection._requests[request_id] = \ + (cb, kwargs.get('decoder'), kwargs.get('result_metadata')) + send_started.set() + assert release_send.wait(5) + return 128 + + connection.send_msg.side_effect = send_msg + rf = self.make_response_future(session) + send_result = [] + send_thread = Thread(target=lambda: send_result.append(rf.send_request())) + send_thread.start() + assert send_started.wait(5) + + # send_request() has not returned to copy its local request id yet, but + # the timeout must still detach the callback send_msg() installed. + assert not rf.attempted_hosts + assert rf._req_id == 11 + rf._start_time = time.time() - 5 + rf._on_speculative_execute() + + release_send.set() + send_thread.join(5) + assert not send_thread.is_alive() + assert send_result == [True] + assert 11 not in connection._requests + assert 11 in connection.orphaned_request_ids + pool.return_connection.assert_called_once_with( + connection, stream_was_orphaned=True) + with pytest.raises(OperationTimedOut): + rf.result() + def _make_fallback_session(self, cluster=None, keyspace=None): session = self.make_basic_session() if cluster is not None: @@ -1281,6 +1333,51 @@ def send_msg(message, request_id, cb, **kwargs): with pytest.raises(OperationTimedOut): rf.result() + def test_control_connection_timeout_does_not_orphan_reused_stream(self): + session = self._make_fallback_session() + connection = self.make_control_connection() + session.cluster.control_connection._connection = connection + + def send_msg(message, request_id, cb, **kwargs): + connection._requests[request_id] = \ + (cb, kwargs.get('decoder'), kwargs.get('result_metadata')) + return 128 + + connection.send_msg.side_effect = send_msg + rf = self.make_response_future(session) + assert rf.send_request() + + # Complete the physical fallback request with a schema change. The + # future remains pending while schema agreement runs, but stream 7 is + # no longer owned by it and may be reused by control traffic. + response_cb, _, _ = connection._requests.pop(7) + response_cb(Mock(spec=ResultMessage, + kind=RESULT_KIND_SCHEMA_CHANGE, + schema_change_event={})) + assert not rf._event.is_set() + assert not rf._control_connection_requests + assert session.cluster.control_connection._application_requests_in_flight == 0 + + unrelated_cb = Mock() + unrelated_decoder = Mock() + unrelated_metadata = Mock() + unrelated_request = \ + (unrelated_cb, unrelated_decoder, unrelated_metadata) + connection._requests[7] = unrelated_request + connection.in_flight = 1 + recovered_pool = Mock(is_shutdown=False) + session._pools = {rf._current_host: recovered_pool} + + rf._on_timeout() + + assert connection._requests[7] is unrelated_request + assert not connection.orphaned_request_ids + recovered_pool.return_connection.assert_not_called() + assert session.cluster.control_connection._application_requests_in_flight == 0 + assert not session.cluster.control_connection._application_orphaned_requests + with pytest.raises(OperationTimedOut): + rf.result() + def test_control_connection_fallback_timeout_barrier_ends_on_late_use(self): session1 = self._make_fallback_session(keyspace='ks1') control_connection = session1.cluster.control_connection From 78de15ff5d890477d3135ffebd35378e75bcd2f7 Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Tue, 22 Sep 2026 20:07:16 -0400 Subject: [PATCH 185/200] reconnection: release stopped host handlers Authentication failures and exhausted retry schedules left their handlers installed, so every later DOWN event treated a stopped handler as active and permanently skipped the host. Release terminal handlers without clearing a concurrent replacement, let later DOWN events start a fresh retry cycle, and serialize pending lifecycle transitions so stale restart work cannot replace newer recovery state. Fixes #1026 --- CHANGELOG.rst | 4 + cassandra/cluster.py | 105 ++++++++-- cassandra/pool.py | 26 ++- tests/unit/test_cluster.py | 384 ++++++++++++++++++++++++++++++++++++- 4 files changed, 498 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5ef0aff5b7..e3eaa2c24e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -55,6 +55,10 @@ Bug Fixes processed before ``send_msg()`` returns. On a later timeout the driver then orphaned the wrong stream id and left the real request in the connection's request map (#1013). +* A host reconnection handler now releases the host's reconnection slot when + authentication fails or its retry schedule is exhausted. A later DOWN event can + therefore start a new handler after credentials recover or another reconnection + opportunity appears, instead of treating the stopped handler as an active one (#1026). Others ------ diff --git a/cassandra/cluster.py b/cassandra/cluster.py index bcc4fd062d..c3f7a96442 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -195,6 +195,7 @@ def new_f(self, *args, **kwargs): try: future = self.executor.submit(f, self, *args, **kwargs) future.add_done_callback(_future_completed) + return future except Exception: log.exception("Failed to submit task to executor") @@ -2067,7 +2068,9 @@ def _on_up_future_completed(self, host, futures, results, lock, finished_future) log.info("Connection pools established for node %s", host) # mark the host as up and notify all listeners - host.set_up() + with host.lock: + host.set_up() + host._pending_host_addition = False for listener in self.listeners: listener.on_up(host) finally: @@ -2145,6 +2148,7 @@ def on_up(self, host): if not have_future: with host.lock: host.set_up() + host._pending_host_addition = False host._currently_handling_node_up = False # for testing purposes @@ -2161,12 +2165,15 @@ def _start_reconnector(self, host, is_host_addition): # of the current Cluster attributes to create new Connections with conn_factory = self._make_connection_factory(host) - reconnector = _HostReconnectionHandler( - host, conn_factory, is_host_addition, self.on_add, self.on_up, - self.scheduler, schedule, host.get_and_set_reconnection_handler, - new_handler=None) - - old_reconnector = host.get_and_set_reconnection_handler(reconnector) + with host.lock: + if is_host_addition: + host._pending_host_addition = True + is_host_addition = host._pending_host_addition + reconnector = _HostReconnectionHandler( + host, conn_factory, is_host_addition, self.on_add, self.on_up, + self.scheduler, schedule, + host.get_and_set_reconnection_handler, new_handler=None) + old_reconnector = host.get_and_set_reconnection_handler(reconnector) if old_reconnector: log.debug("Old host reconnector found for %s, cancelling", host) old_reconnector.cancel() @@ -2175,16 +2182,46 @@ def _start_reconnector(self, host, is_host_addition): reconnector.start() @run_in_executor - def on_down_potentially_blocking(self, host, is_host_addition): - self.profile_manager.on_down(host) - self.control_connection.on_down(host) - for session in tuple(self.sessions): - session.on_down(host) + def on_down_potentially_blocking(self, host, is_host_addition, + down_event_generation): + try: + with host.lock: + if down_event_generation != host._down_event_generation: + return - for listener in self.listeners: - listener.on_down(host) + self.profile_manager.on_down(host) + self.control_connection.on_down(host) + for session in tuple(self.sessions): + session.on_down(host) + + for listener in self.listeners: + listener.on_down(host) - self._start_reconnector(host, is_host_addition) + self._start_reconnector(host, is_host_addition) + finally: + with host.lock: + if down_event_generation == host._down_event_generation: + host._currently_handling_node_down = False + + @run_in_executor + def _restart_reconnector(self, host, is_host_addition, + down_event_generation): + try: + # Match the old synchronous path's ordering with on_up(): if the + # host recovered or is still being brought up while this task was + # queued, there is nothing to restart. A failed UP transition + # starts its own reconnector before releasing the UP guard, which + # must not be replaced by this stale task. + with host.lock: + if (down_event_generation == host._down_event_generation and + host.is_up is False and + not host._currently_handling_node_up and + not host.is_currently_reconnecting()): + self._start_reconnector(host, is_host_addition) + finally: + with host.lock: + if down_event_generation == host._down_event_generation: + host._currently_handling_node_down = False def on_down(self, host, is_host_addition, expect_host_to_be_down=False): """ @@ -2193,6 +2230,7 @@ def on_down(self, host, is_host_addition, expect_host_to_be_down=False): if self.is_shutdown or self.allow_control_connection_query_fallback == ControlConnectionQueryFallback.SkipPoolCreation: return + restart_reconnector = False with host.lock: was_up = host.is_up @@ -2209,16 +2247,46 @@ def on_down(self, host, is_host_addition, expect_host_to_be_down=False): return host.set_down() - if (not was_up and not expect_host_to_be_down) or host.is_currently_reconnecting(): + if (not was_up and + (host.is_currently_reconnecting() or + host._currently_handling_node_down)): return + + if not was_up and not expect_host_to_be_down: + # A terminal failure may have released this down host's old + # handler. Start a new retry cycle without repeating the DOWN + # notifications that were sent on the original transition. + restart_reconnector = True + + host._currently_handling_node_down = True + host._down_event_generation += 1 + down_event_generation = host._down_event_generation + + if restart_reconnector: + future = self._restart_reconnector( + host, is_host_addition, down_event_generation) + if future is None: + with host.lock: + if down_event_generation == host._down_event_generation: + host._currently_handling_node_down = False + return + log.warning("Host %s has been marked down", host) - self.on_down_potentially_blocking(host, is_host_addition) + future = self.on_down_potentially_blocking( + host, is_host_addition, down_event_generation) + if future is None: + with host.lock: + if down_event_generation == host._down_event_generation: + host._currently_handling_node_down = False def on_add(self, host, refresh_nodes=True): if self.is_shutdown: return + with host.lock: + host._pending_host_addition = True + log.debug("Handling new host %r and notifying listeners", host) self.profile_manager.on_add(host) @@ -2281,6 +2349,9 @@ def _finalize_add(self, host, set_up=True): for listener in self.listeners: listener.on_add(host) + with host.lock: + host._pending_host_addition = False + # see if there are any pools to add or remove now that the host is marked up for session in tuple(self.sessions): session.update_created_pools() diff --git a/cassandra/pool.py b/cassandra/pool.py index 112a11a54d..8cc22018d2 100644 --- a/cassandra/pool.py +++ b/cassandra/pool.py @@ -161,6 +161,9 @@ class Host(object): lock = None _currently_handling_node_up = False + _currently_handling_node_down = False + _down_event_generation = 0 + _pending_host_addition = False sharding_info = None @@ -355,6 +358,18 @@ def __init__(self, host, connection_factory, is_host_addition, on_add, on_up, *a self.host = host self.connection_factory = connection_factory + def start(self): + try: + _ReconnectionHandler.start(self) + except StopIteration: + # An empty schedule means this handler will never run. + self._release_slot() + + def _release_slot(self): + with self.host.lock: + if self.host._reconnection_handler is self: + self.host._reconnection_handler = None + def try_reconnect(self): return self.connection_factory() @@ -367,12 +382,19 @@ def on_reconnection(self, connection): def on_exception(self, exc, next_delay): if isinstance(exc, AuthenticationFailed): - return False + keep_retrying = False else: log.warning("Error attempting to reconnect to %s, scheduling retry in %s seconds: %s", self.host, next_delay, exc) log.debug("Reconnection error details", exc_info=True) - return True + keep_retrying = True + + if not keep_retrying or next_delay is None: + # This handler will never run again. Release the slot it occupies, + # or a later DOWN event will mistake it for a live reconnector. + self._release_slot() + + return keep_retrying class HostConnection(object): diff --git a/tests/unit/test_cluster.py b/tests/unit/test_cluster.py index 0aad0cfa69..d6950106b1 100644 --- a/tests/unit/test_cluster.py +++ b/tests/unit/test_cluster.py @@ -34,8 +34,8 @@ DefaultEndPoint) from cassandra.ssl_session_cache import SSLSessionCache from cassandra.driver_config import DriverConfigReporter -from cassandra.pool import Host -from cassandra.policies import HostDistance, RetryPolicy, RoundRobinPolicy, DowngradingConsistencyRetryPolicy, SimpleConvictionPolicy +from cassandra.pool import Host, _HostReconnectionHandler +from cassandra.policies import ExponentialReconnectionPolicy, HostDistance, RetryPolicy, RoundRobinPolicy, DowngradingConsistencyRetryPolicy, SimpleConvictionPolicy from cassandra.query import SimpleStatement, named_tuple_factory, tuple_factory from tests.unit.utils import mock_session_pools from tests import connection_class @@ -435,6 +435,386 @@ def test_connection_factory_ignores_a_caller_supplied_session_id_and_reporter(se assert factory.call_args.kwargs['driver_config_reporter'] is None +class HostReconnectionHandlerTest(unittest.TestCase): + + def setUp(self): + self.host = Host( + "127.0.0.1", SimpleConvictionPolicy, host_id=uuid.uuid4()) + + def make_handler(self, connection_factory=None, schedule=None, scheduler=None): + handler = _HostReconnectionHandler( + self.host, connection_factory or Mock(), False, Mock(), Mock(), + scheduler or Mock(), schedule if schedule is not None else iter(()), + self.host.get_and_set_reconnection_handler, new_handler=None) + self.host.get_and_set_reconnection_handler(handler) + return handler + + def test_releases_slot_when_it_gives_up(self): + cases = ( + (AuthenticationFailed('bad credentials'), iter((0, 1.0))), + (ConnectionException('refused'), iter((0,))), + ) + for exc, schedule in cases: + with self.subTest(exc=exc): + handler = self.make_handler( + connection_factory=Mock(side_effect=exc), + schedule=schedule) + + handler.start() + handler.run() + + assert not self.host.is_currently_reconnecting() + + def test_keeps_slot_while_retrying(self): + scheduler = Mock() + handler = self.make_handler( + connection_factory=Mock(side_effect=ConnectionException('refused')), + schedule=iter((0, 1.0)), scheduler=scheduler) + + handler.start() + handler.run() + + assert self.host._reconnection_handler is handler + assert scheduler.schedule.call_count == 2 + + def test_releases_slot_when_initial_schedule_is_empty(self): + scheduler = Mock() + schedule = ExponentialReconnectionPolicy( + 1.0, 60.0, max_attempts=0).new_schedule() + handler = self.make_handler(schedule=schedule, scheduler=scheduler) + + handler.start() + + assert not self.host.is_currently_reconnecting() + scheduler.schedule.assert_not_called() + + def test_never_releases_replacement(self): + handler = self.make_handler() + replacement = self.make_handler() + + handler.on_exception(AuthenticationFailed('bad credentials'), 1.0) + + assert self.host._reconnection_handler is replacement + + def test_later_down_event_restarts_after_terminal_failure(self): + cluster = Cluster() + cluster.scheduler.shutdown() + cluster.scheduler = Mock() + cluster.executor.shutdown() + cluster.executor = Mock() + self.addCleanup(cluster.shutdown) + cluster._discount_down_events = False + cluster.reconnection_policy = Mock() + cluster.reconnection_policy.new_schedule.side_effect = ( + iter((0, 1.0)), iter((0, 1.0))) + connection_factory = Mock( + side_effect=AuthenticationFailed('bad credentials')) + cluster._make_connection_factory = Mock( + return_value=connection_factory) + cluster.profile_manager.distance = Mock( + return_value=HostDistance.LOCAL) + cluster.metadata.add_or_return_host(self.host) + self.host.set_down() + + cluster._start_reconnector(self.host, is_host_addition=False) + stopped_handler = self.host._reconnection_handler + stopped_handler.run() + assert not self.host.is_currently_reconnecting() + + pending_future = Future() + cluster.executor.submit.return_value = pending_future + cluster.on_down(self.host, is_host_addition=False) + + assert self.host._reconnection_handler is None + assert self.host._currently_handling_node_down + cluster.on_down(self.host, is_host_addition=False) + cluster.executor.submit.assert_called_once() + + restart_task, *args = cluster.executor.submit.call_args.args + restart_task(*args) + + assert self.host._reconnection_handler is not stopped_handler + assert self.host.is_currently_reconnecting() + assert not self.host._currently_handling_node_down + + def test_later_down_preserves_pending_host_addition(self): + cluster = Cluster() + cluster.scheduler.shutdown() + cluster.scheduler = Mock() + cluster.executor.shutdown() + cluster.executor = Mock() + self.addCleanup(cluster.shutdown) + cluster._discount_down_events = False + cluster.reconnection_policy = Mock() + cluster.reconnection_policy.new_schedule.side_effect = ( + iter((0, 1.0)), iter((0, 1.0))) + connection = Mock() + connection_factory = Mock(side_effect=( + AuthenticationFailed('bad credentials'), connection)) + cluster._make_connection_factory = Mock( + return_value=connection_factory) + cluster.profile_manager.distance = Mock( + return_value=HostDistance.LOCAL) + cluster.profile_manager.on_add = Mock() + cluster.control_connection.on_add = Mock() + cluster._prepare_all_queries = Mock() + cluster.on_up = Mock() + listener = Mock() + cluster.register_listener(listener) + cluster.metadata.add_or_return_host(self.host) + self.host.set_down() + + cluster._start_reconnector(self.host, is_host_addition=True) + stopped_handler = self.host._reconnection_handler + stopped_handler.run() + assert not self.host.is_currently_reconnecting() + + cluster.executor.submit.return_value = Future() + cluster.on_down(self.host, is_host_addition=False) + restart_task, *args = cluster.executor.submit.call_args.args + restart_task(*args) + replacement_handler = self.host._reconnection_handler + replacement_handler.run() + + assert replacement_handler.is_host_addition + listener.on_add.assert_called_once_with(self.host) + cluster.on_up.assert_not_called() + assert self.host.is_up + assert not self.host._pending_host_addition + + def test_queued_reconnector_restart_skips_recovered_host(self): + cluster = Cluster() + cluster.executor.shutdown() + cluster.executor = Mock() + self.addCleanup(cluster.shutdown) + cluster._discount_down_events = False + cluster.metadata.add_or_return_host(self.host) + cluster._start_reconnector = Mock() + cluster.executor.submit.return_value = Future() + self.host.set_down() + + cluster.on_down(self.host, is_host_addition=False) + restart_task, *args = cluster.executor.submit.call_args.args + self.host.set_up() + restart_task(*args) + + cluster._start_reconnector.assert_not_called() + assert not self.host._currently_handling_node_down + + def test_fresh_down_supersedes_queued_reconnector_restart(self): + cluster = Cluster() + cluster.executor.shutdown() + cluster.executor = Mock() + self.addCleanup(cluster.shutdown) + cluster._discount_down_events = False + cluster.metadata.add_or_return_host(self.host) + cluster.profile_manager.on_down = Mock() + cluster.control_connection.on_down = Mock() + cluster._start_reconnector = Mock() + session = Mock() + listener = Mock() + cluster.sessions.add(session) + cluster.register_listener(listener) + cluster.executor.submit.return_value = Future() + self.host.set_down() + + cluster.on_down(self.host, is_host_addition=False) + self.host.set_up() + cluster.on_down(self.host, is_host_addition=False) + + assert cluster.executor.submit.call_count == 2 + restart_task, *restart_args = ( + cluster.executor.submit.call_args_list[0].args) + down_task, *down_args = cluster.executor.submit.call_args_list[1].args + + restart_task(*restart_args) + assert self.host._currently_handling_node_down + cluster._start_reconnector.assert_not_called() + + down_task(*down_args) + cluster.profile_manager.on_down.assert_called_once_with(self.host) + cluster.control_connection.on_down.assert_called_once_with(self.host) + session.on_down.assert_called_once_with(self.host) + listener.on_down.assert_called_once_with(self.host) + cluster._start_reconnector.assert_called_once_with(self.host, False) + assert not self.host._currently_handling_node_down + + def test_queued_reconnector_restart_skips_pending_up(self): + cluster = Cluster() + cluster.scheduler.shutdown() + cluster.scheduler = Mock() + cluster.executor.shutdown() + cluster.executor = Mock() + self.addCleanup(cluster.shutdown) + cluster._discount_down_events = False + cluster.metadata.add_or_return_host(self.host) + cluster.profile_manager.distance = Mock( + return_value=HostDistance.LOCAL) + cluster.profile_manager.on_up = Mock() + cluster.profile_manager.on_down = Mock() + cluster.control_connection.on_up = Mock() + cluster.control_connection.on_down = Mock() + cluster._prepare_all_queries = Mock() + session = Mock() + pool_future = Future() + session.add_or_renew_pool.return_value = pool_future + cluster.sessions.add(session) + cluster.executor.submit.return_value = Future() + self.host.set_down() + + cluster.on_up(self.host) + assert self.host._currently_handling_node_up + + cluster.on_down(self.host, is_host_addition=False) + restart_task, *args = cluster.executor.submit.call_args.args + restart_task(*args) + + assert not self.host.is_currently_reconnecting() + assert not self.host._currently_handling_node_down + + pool_future.set_result(True) + assert self.host.is_up + assert not self.host._currently_handling_node_up + + cluster.on_down(self.host, is_host_addition=False) + assert cluster.executor.submit.call_count == 2 + down_task, *args = cluster.executor.submit.call_args.args + down_task(*args) + + cluster.profile_manager.on_down.assert_called_once_with(self.host) + cluster.control_connection.on_down.assert_called_once_with(self.host) + session.on_down.assert_called_once_with(self.host) + + def test_queued_reconnector_restart_preserves_failed_up_handler(self): + cluster = Cluster() + cluster.scheduler.shutdown() + cluster.scheduler = Mock() + cluster.executor.shutdown() + cluster.executor = Mock() + self.addCleanup(cluster.shutdown) + cluster._discount_down_events = False + cluster.metadata.add_or_return_host(self.host) + cluster.profile_manager.distance = Mock( + return_value=HostDistance.LOCAL) + cluster.profile_manager.on_up = Mock() + cluster.profile_manager.on_down = Mock() + cluster.control_connection.on_up = Mock() + cluster.control_connection.on_down = Mock() + cluster._prepare_all_queries = Mock() + cluster._make_connection_factory = Mock(return_value=Mock()) + session = Mock() + pool_future = Future() + session.add_or_renew_pool.return_value = pool_future + cluster.sessions.add(session) + cluster.executor.submit.return_value = Future() + self.host.set_down() + + cluster.on_up(self.host) + cluster.on_down(self.host, is_host_addition=False) + restart_task, *args = cluster.executor.submit.call_args.args + + pool_future.set_result(False) + failed_up_handler = self.host._reconnection_handler + assert failed_up_handler is not None + assert not self.host._currently_handling_node_up + + restart_task(*args) + + assert self.host._reconnection_handler is failed_up_handler + assert not failed_up_handler._cancelled + assert cluster.scheduler.schedule.call_count == 1 + assert not self.host._currently_handling_node_down + + def test_successful_up_resolves_pending_host_addition(self): + cluster = Cluster() + cluster.scheduler.shutdown() + cluster.scheduler = Mock() + cluster.executor.shutdown() + cluster.executor = Mock() + self.addCleanup(cluster.shutdown) + cluster._discount_down_events = False + cluster.metadata.add_or_return_host(self.host) + cluster.profile_manager.distance = Mock( + return_value=HostDistance.LOCAL) + cluster.profile_manager.on_add = Mock() + cluster.profile_manager.on_up = Mock() + cluster.profile_manager.on_down = Mock() + cluster.control_connection.on_add = Mock() + cluster.control_connection.on_up = Mock() + cluster.control_connection.on_down = Mock() + cluster._prepare_all_queries = Mock() + cluster._make_connection_factory = Mock(return_value=Mock()) + listener = Mock() + cluster.register_listener(listener) + session = Mock() + failed_add_future = Future() + up_future = Future() + session.add_or_renew_pool.side_effect = ( + failed_add_future, up_future) + cluster.sessions.add(session) + cluster.executor.submit.return_value = Future() + + cluster.on_add(self.host) + failed_add_future.set_result(False) + assert self.host._pending_host_addition + listener.on_add.assert_not_called() + + cluster.on_up(self.host) + up_future.set_result(True) + + assert self.host.is_up + assert not self.host._pending_host_addition + listener.on_up.assert_called_once_with(self.host) + + cluster.on_down(self.host, is_host_addition=False) + down_task, *args = cluster.executor.submit.call_args.args + down_task(*args) + + assert not self.host._reconnection_handler.is_host_addition + listener.on_add.assert_not_called() + + def test_repeated_down_waits_for_pending_down_processing(self): + cluster = Cluster() + cluster.scheduler.shutdown() + cluster.scheduler = Mock() + cluster.executor.shutdown() + cluster.executor = Mock() + self.addCleanup(cluster.shutdown) + cluster._discount_down_events = False + cluster.metadata.add_or_return_host(self.host) + cluster.profile_manager.on_down = Mock() + cluster.control_connection.on_down = Mock() + cluster._start_reconnector = Mock() + pending_future = Future() + cluster.executor.submit.return_value = pending_future + self.host.set_up() + + cluster.on_down(self.host, is_host_addition=False) + cluster.on_down(self.host, is_host_addition=False) + + cluster.executor.submit.assert_called_once() + cluster._start_reconnector.assert_not_called() + + down_task, *args = cluster.executor.submit.call_args.args + down_task(*args) + + cluster._start_reconnector.assert_called_once_with(self.host, False) + assert not self.host._currently_handling_node_down + + def test_failed_down_submission_releases_pending_state(self): + cluster = Cluster() + cluster.executor.shutdown() + cluster.executor = Mock() + self.addCleanup(cluster.shutdown) + cluster._discount_down_events = False + cluster.metadata.add_or_return_host(self.host) + cluster.executor.submit.side_effect = RuntimeError('executor stopped') + self.host.set_up() + + cluster.on_down(self.host, is_host_addition=False) + + assert not self.host._currently_handling_node_down + class SchedulerTest(unittest.TestCase): # TODO: this suite could be expanded; for now just adding a test covering a ticket From 56515098b029395e71862288a7b4a0e67f8cfe42 Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Tue, 22 Sep 2026 23:16:33 -0400 Subject: [PATCH 186/200] cluster: finalize additions before listener callbacks Clear the pending host addition atomically with marking the host up, so listener callbacks observe a completed lifecycle. This prevents concurrent DOWN handling from starting an add-mode reconnector and ensures listener exceptions cannot leave the flag stuck.\n\nCover both cases by sending DOWN and raising from an on_add listener. --- cassandra/cluster.py | 9 ++++----- tests/unit/test_cluster.py | 39 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index c3f7a96442..f8859a5659 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -2343,15 +2343,14 @@ def future_completed(future): self._finalize_add(host) def _finalize_add(self, host, set_up=True): - if set_up: - host.set_up() + with host.lock: + if set_up: + host.set_up() + host._pending_host_addition = False for listener in self.listeners: listener.on_add(host) - with host.lock: - host._pending_host_addition = False - # see if there are any pools to add or remove now that the host is marked up for session in tuple(self.sessions): session.update_created_pools() diff --git a/tests/unit/test_cluster.py b/tests/unit/test_cluster.py index d6950106b1..4792083817 100644 --- a/tests/unit/test_cluster.py +++ b/tests/unit/test_cluster.py @@ -773,6 +773,45 @@ def test_successful_up_resolves_pending_host_addition(self): assert not self.host._reconnection_handler.is_host_addition listener.on_add.assert_not_called() + def test_successful_add_resolves_pending_state_before_listener(self): + cluster = Cluster() + cluster.scheduler.shutdown() + cluster.scheduler = Mock() + cluster.executor.shutdown() + cluster.executor = Mock() + self.addCleanup(cluster.shutdown) + cluster._discount_down_events = False + cluster.metadata.add_or_return_host(self.host) + cluster.profile_manager.distance = Mock( + return_value=HostDistance.LOCAL) + cluster.profile_manager.on_add = Mock() + cluster.profile_manager.on_down = Mock() + cluster.control_connection.on_add = Mock() + cluster.control_connection.on_down = Mock() + cluster._prepare_all_queries = Mock() + cluster._make_connection_factory = Mock(return_value=Mock()) + cluster.executor.submit.return_value = Future() + listener = Mock() + cluster.register_listener(listener) + + def on_add(host): + assert host.is_up + assert not host._pending_host_addition + + cluster.on_down(host, is_host_addition=False) + down_task, *args = cluster.executor.submit.call_args.args + down_task(*args) + + raise RuntimeError('listener failed') + + listener.on_add.side_effect = on_add + + with pytest.raises(RuntimeError, match='listener failed'): + cluster.on_add(self.host) + + assert not self.host._pending_host_addition + assert not self.host._reconnection_handler.is_host_addition + def test_repeated_down_waits_for_pending_down_processing(self): cluster = Cluster() cluster.scheduler.shutdown() From 8ef2364bf40acf08161330b4d8d1a1bd97143747 Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Thu, 24 Sep 2026 11:20:38 -0400 Subject: [PATCH 187/200] control-connection: reconnect when down handling is skipped (#1024) * control-connection: reconnect when DOWN handling is skipped ControlConnection._signal_error() treated a conviction-policy decision as proof that Cluster.on_down() had dispatched its asynchronous callbacks. DOWN handling can instead be suppressed by an open session pool, existing host state, disabled pool creation, shutdown, or a rejected executor submission, leaving the defunct control connection without recovery work. Report whether DOWN handling was actually queued and reconnect directly when it was not. Keep callback-driven recovery for accepted transitions so the normal host-down path does not submit a duplicate attempt, and cover TCP, Unix-socket, alternate-route, and stable host-ID connections. Fixes #847 * control-connection: preserve reconnect ownership The direct recovery path can overlap queued attempts, heartbeat returns, topology-driven replacements, and finite retry schedules. Without a single owner for pending and delayed work, later triggers can reset backoff, stale handlers can replace healthy connections, and exhausted handlers can suppress an independent failure. Serialize reconnect submissions, retain triggers until an active attempt or handler owns them, and release handler slots with identity checks. Preserve failures that arrive during proactive retries, honor empty and exhausted schedules, and keep a successfully restored control connection open while still closing host-probe connections. Make connection installation and shutdown races explicit so an overlapping failed attempt cannot park retries over a newer connection and an orphaned connection is closed. * changelog: document control-connection recovery * control-connection: preserve DOWN recovery callbacks Isolate session and listener failures so asynchronous DOWN handling still starts host reconnection after reporting successful dispatch. Keep heartbeat submission outside the reconnection lock and use the control-handler type invariant directly.\n\nDocument policy-driven retry cadence and finite-schedule exhaustion. * control-connection: preserve overlapping retry state A zero-delay retry can start on another executor worker before the previous handler invocation reaches its finally block. Prevent the stale invocation from clearing the newer run's active marker so heartbeat triggers during a final failed attempt remain eligible for recovery. * control-connection: clarify finite retry exhaustion A reconnect trigger received while the final scheduled attempt is running starts a fresh schedule if that attempt fails. Document this timing-dependent exception while retaining the distinction for heartbeat returns after exhaustion. --- CHANGELOG.rst | 39 ++ cassandra/cluster.py | 487 +++++++++++--- cassandra/pool.py | 14 +- tests/unit/test_cluster.py | 259 ++++++++ tests/unit/test_control_connection.py | 909 ++++++++++++++++++++++++-- 5 files changed, 1578 insertions(+), 130 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e3eaa2c24e..1b1c2c4132 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -59,6 +59,45 @@ Bug Fixes authentication fails or its retry schedule is exhausted. A later DOWN event can therefore start a new handler after credentials recover or another reconnection opportunity appears, instead of treating the stopped handler as an active one (#1026). +* A defunct control connection is no longer left unreconnected when the cluster does not + run DOWN handling for its host (#847). ``ControlConnection._signal_error()`` treated the + conviction policy accepting a failure as a guarantee that a DOWN callback would + reconnect it, but the two are not the same: ``Cluster.on_down()`` deliberately skips + DOWN handling when a session pool to the host is still open, when the host is already + down or already reconnecting, and when pool creation is disabled -- and the default + ``SimpleConvictionPolicy`` rejects the conviction outright for ``OperationTimedOut``. + In all of those cases the control connection stayed defunct with nothing scheduled to + replace it. ``Cluster.on_down()`` and ``Cluster.signal_connection_failure()`` now return + whether DOWN handling was actually dispatched, and the control connection reconnects + directly whenever it was not. This applies uniformly to TCP, Unix socket, + alternate-route and stable host-ID connections, and does not duplicate the reconnect + that an accepted DOWN transition already performs. Two related cases are fixed with + it: a control connection whose reconnection attempts are already backing off no longer + has that schedule cancelled and restarted from its initial delay by every further + error, and a reconnection handler that has stopped for good -- once its retry schedule + is exhausted -- now releases the slot it occupies, so a later error starts a fresh + reconnection instead of mistaking the dead handler for one still retrying. The slot is + likewise released once a connection is installed, so a handler whose backoff outlived + the reconnection that succeeded without it no longer blocks the next one. Every release + is identity-checked, so a handler that stops can only ever clear itself: it cannot evict + a replacement another thread installed while it was handing its connection over, which + would have left that replacement retrying where nothing could find it. An attempt that + fails while an overlapping one succeeds no longer parks a handler in the slot the + successful attempt emptied, which would have blocked reconnection for the length of its + backoff and then replaced a healthy control connection. Once retry handling begins, its + cadence follows ``reconnection_policy`` up to ``max_reconnection_delay`` instead of + being restarted by each ``idle_heartbeat_interval``. Operators that need a shorter + recovery bound should configure a lower maximum delay. A finite schedule gives up after + its configured attempts only when its final attempt does not overlap another reconnect + trigger. A trigger received while the final attempt is running starts a fresh schedule if + that attempt fails, so reconnection can be retriggered indefinitely when attempts outlast + ``idle_heartbeat_interval``. Heartbeat returns after exhaustion for the same failed + connection do not re-arm it; an infinite schedule provides timing-independent recovery. +* The control connection is no longer closed immediately after a reconnection handler + restores it. ``_ReconnectionHandler.run()`` closed the connection it had just opened, + which is right for the host handler that only uses it to probe the host, but left the + control connection dead the moment its backoff finally succeeded -- until a heartbeat + noticed, or forever with ``idle_heartbeat_interval=0``. Others ------ diff --git a/cassandra/cluster.py b/cassandra/cluster.py index f8859a5659..2f9f9426fb 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -185,6 +185,10 @@ def _future_completed(future): def run_in_executor(f): """ A decorator to run the given method in the ThreadPoolExecutor. + + The wrapper returns the submitted Future, or None when the cluster is + shutting down or the executor rejected the submission, in which case the + wrapped method never runs. """ @wraps(f) @@ -2189,13 +2193,32 @@ def on_down_potentially_blocking(self, host, is_host_addition, if down_event_generation != host._down_event_generation: return - self.profile_manager.on_down(host) - self.control_connection.on_down(host) + # The load balancing policies go first, so that the query plan the + # control connection's reconnect walks no longer offers the host + # that just went down; otherwise the reconnect submitted below can + # start before the policies have dropped it and burn a + # connect_timeout on a dead node. Both calls are guarded because + # on_down() reports successful dispatch to callers that rely on a + # reconnection being started. + try: + self.profile_manager.on_down(host) + except Exception: + log.exception("Error in load balancing policy down handler for host %s", host) + try: + self.control_connection.on_down(host) + except Exception: + log.exception("Error in control connection down handler for host %s", host) for session in tuple(self.sessions): - session.on_down(host) + try: + session.on_down(host) + except Exception: + log.exception("Error marking host %s down in session", host) for listener in self.listeners: - listener.on_down(host) + try: + listener.on_down(host) + except Exception: + log.exception("Error in host state listener down handler for host %s", host) self._start_reconnector(host, is_host_addition) finally: @@ -2226,9 +2249,11 @@ def _restart_reconnector(self, host, is_host_addition, def on_down(self, host, is_host_addition, expect_host_to_be_down=False): """ Intended for internal use only. + + Returns whether DOWN handling was dispatched. """ if self.is_shutdown or self.allow_control_connection_query_fallback == ControlConnectionQueryFallback.SkipPoolCreation: - return + return False restart_reconnector = False with host.lock: @@ -2244,13 +2269,13 @@ def on_down(self, host, is_host_addition, expect_host_to_be_down=False): if pool_state: connected |= pool_state['open_count'] > 0 if connected: - return + return False host.set_down() if (not was_up and (host.is_currently_reconnecting() or host._currently_handling_node_down)): - return + return False if not was_up and not expect_host_to_be_down: # A terminal failure may have released this down host's old @@ -2269,7 +2294,9 @@ def on_down(self, host, is_host_addition, expect_host_to_be_down=False): with host.lock: if down_event_generation == host._down_event_generation: host._currently_handling_node_down = False - return + # Restarting the host reconnector does not dispatch the DOWN + # callbacks that reconnect the control connection. + return False log.warning("Host %s has been marked down", host) @@ -2279,6 +2306,7 @@ def on_down(self, host, is_host_addition, expect_host_to_be_down=False): with host.lock: if down_event_generation == host._down_event_generation: host._currently_handling_node_down = False + return future is not None def on_add(self, host, refresh_nodes=True): if self.is_shutdown: @@ -2373,10 +2401,11 @@ def on_remove(self, host): reconnection_handler.cancel() def signal_connection_failure(self, host, connection_exc, is_host_addition, expect_host_to_be_down=False): + """Return whether the failure caused DOWN handling to be dispatched.""" is_down = host.signal_connection_failure(connection_exc) - if is_down: - self.on_down(host, is_host_addition, expect_host_to_be_down) - return is_down + if not is_down: + return False + return self.on_down(host, is_host_addition, expect_host_to_be_down) def add_host(self, endpoint, datacenter=None, rack=None, signal=True, refresh_nodes=True, host_id=None): """ @@ -3971,23 +4000,133 @@ class _ControlReconnectionHandler(_ReconnectionHandler): Internal """ - def __init__(self, control_connection, *args, **kwargs): - _ReconnectionHandler.__init__(self, *args, **kwargs) + # on_reconnection() installs the connection as the control connection, + # so it must outlive the handler run that opened it. + _keeps_connection = True + + def __init__(self, control_connection, scheduler, schedule): + # The run-completed callback releases this handler's own slot. It is + # identity-checked, so a run that finishes after another thread has + # installed a replacement leaves that replacement alone. Anything that + # evicts a handler must also cancel it, or the evicted one keeps + # retrying where nothing can find it. + _ReconnectionHandler.__init__(self, scheduler, schedule, self._release) self.control_connection = weakref.proxy(control_connection) + # Remember a failed connection that caused this retry run. If the + # finite schedule is exhausted without a reconnect trigger retained + # from its final running attempt, the heartbeat will keep returning + # this same object and must not restart the schedule. + # A healthy connection may also be replaced after a topology event; + # exhausting that attempt must not suppress recovery if the connection + # fails later for an independent reason. + connection = control_connection._connection + self._failed_connection = connection \ + if connection is not None and ( + connection.is_defunct or connection.is_closed) else None + # A reconnect trigger that arrives while an attempt is actually + # running is normally covered by that attempt. If this turns out to be + # the final failed attempt, though, there is no later retry to own the + # trigger, so _release() must hand it back to reconnect(). + self._is_running = False + self._run_generation = 0 + self._reconnect_requested = False + # A proactive handler may be waiting while its still-healthy control + # connection fails. Its remaining retries own the immediate recovery, + # but the failure must receive a fresh schedule if those retries are + # exhausted. Unlike a trigger received during a running attempt, this + # state therefore survives every non-final handler run. + self._new_failure_detected = False + + def run(self): + try: + control_connection = self.control_connection + with control_connection._reconnection_lock: + self._run_generation += 1 + run_generation = self._run_generation + self._is_running = True + except ReferenceError: + return _ReconnectionHandler.run(self) + + try: + _ReconnectionHandler.run(self) + finally: + try: + with control_connection._reconnection_lock: + # A zero-delay retry may already be running on another + # worker. Only the invocation that most recently claimed + # the state may clear it. + if self._run_generation == run_generation: + self._is_running = False + except ReferenceError: + pass def try_reconnect(self): return self.control_connection._reconnect_internal() def on_reconnection(self, connection): - self.control_connection._set_new_connection(connection) + try: + # Resolving the attribute is what dereferences the weak proxy, so + # it is bound here rather than called directly: a ReferenceError + # raised from inside _set_new_connection() would mean the + # connection was already adopted, and must not be closed. + set_new_connection = self.control_connection._set_new_connection + except ReferenceError: + # The ControlConnection was collected while we were retrying. The + # handler has already marked the connection as handed off, so + # nothing else would ever close it. + connection.close() + return + set_new_connection(connection) def on_exception(self, exc, next_delay): - # TODO only overridden to add logging, so add logging - if isinstance(exc, AuthenticationFailed): - return False + # Every reason to stop retrying is worth retrying here: an attempt + # covers the whole query plan, so an authentication failure is a + # failure of the host it happened to reach, not of the cluster. + log.debug("Error trying to reconnect control connection: %r", exc) + + if next_delay is None: + # The schedule is exhausted, so this handler will never run again. + # Release the slot it occupies while remembering the connection + # whose retries were exhausted. A trigger retained from this final + # running attempt starts a fresh schedule; otherwise heartbeats for + # the same defunct connection must not reset the finite schedule. + self._release(exhausted=True) else: - log.debug("Error trying to reconnect control connection: %r", exc) - return True + # This failure already has another scheduled attempt to own any + # trigger that arrived while it was running. + try: + control_connection = self.control_connection + with control_connection._reconnection_lock: + self._is_running = False + self._reconnect_requested = False + except ReferenceError: + pass + + return True + + def _release(self, exhausted=False): + reconnect_requested = False + try: + control_connection = self.control_connection + with control_connection._reconnection_lock: + if control_connection._reconnection_handler is self: + control_connection._reconnection_handler = None + reconnect_requested = exhausted and ( + self._reconnect_requested or + self._new_failure_detected) + self._reconnect_requested = False + self._new_failure_detected = False + if (exhausted and not reconnect_requested and + self._failed_connection is not None and + control_connection._connection is + self._failed_connection): + control_connection._reconnection_exhausted_connection = \ + self._failed_connection + except ReferenceError: + pass # our weak reference to the ControlConnection is no good + + if reconnect_requested: + control_connection.reconnect() def _watch_callback(obj_weakref, method_name, *args, **kwargs): @@ -4084,6 +4223,24 @@ def __init__(self, cluster, timeout, self._reconnection_handler = None self._reconnection_lock = RLock() + # A finite retry schedule may be exhausted while the heartbeat still + # owns and returns the same defunct connection. Keep that identity so + # heartbeat passes do not create a fresh schedule indefinitely. + self._reconnection_exhausted_connection = None + self._reconnect_pending = False + # Set when reconnect() is called while an attempt is already pending. + # If that attempt fails without installing a retry handler, its final + # action consumes this flag by queuing one follow-up attempt. + self._reconnect_requested = False + # Bumped every time _reconnect_pending is raised. An attempt clears the + # flag only while it still owns it: _set_new_connection() drops the flag + # as the connection goes in, so a newer reconnect() can claim it before + # the attempt that installed the connection reaches its finally clause, + # and clearing it there would strand the attempt that newer flag owns. + self._reconnect_pending_seq = 0 + # Bumped every time a connection is installed. _reconnect() compares it + # across its attempt to tell whether another attempt got there first. + self._connection_generation = 0 self._event_schedule_times = {} @@ -4113,20 +4270,61 @@ def connect(self): self._protocol_version = self._cluster.protocol_version self._set_new_connection(self._reconnect_internal()) - self._cluster.metadata.dbaas = self._connection._product_type == dscloud.DATASTAX_CLOUD_PRODUCT_TYPE + # _set_new_connection declines to install anything once shutdown() has + # run, so there may be no connection to ask. + if self._connection: + self._cluster.metadata.dbaas = self._connection._product_type == dscloud.DATASTAX_CLOUD_PRODUCT_TYPE def _set_new_connection(self, conn): """ - Replace existing connection (if there is one) and close it. + Adopt `conn` as the control connection, closing the one it replaces. + + Also ends any reconnection in progress: a handler parked in the slot is + cancelled and cleared, because it would otherwise be mistaken for one + still retrying and its next attempt would replace this connection. + + If the ControlConnection has already been shut down, `conn` is not + installed at all -- nothing would ever use it or close it on our + behalf, so it is closed here instead. """ - with self._lock: - old = self._connection - self._connection = conn + # Whatever put this connection in place, the reconnection is over. A + # handler left parked in the slot would be mistaken for one still + # retrying, and its next attempt would replace this connection. + # + # _reconnection_lock is held across the install so that the generation + # bump is visible to any _reconnect() that is about to decide whether + # another attempt beat it; releasing it first leaves a window where a + # failing attempt reads the old generation and parks a handler over + # this healthy connection. + with self._reconnection_lock: + if self._reconnection_handler: + self._reconnection_handler.cancel() + self._reconnection_handler = None + + # A connection is in place, so an error on it must be able to queue + # a fresh attempt rather than be collapsed into the one ending here. + self._reconnect_pending = False + self._reconnect_requested = False + + with self._lock: + if self._is_shutdown: + # shutdown() won the race, so nothing would ever use this + # connection or close it on our behalf. + old, orphan = None, conn + else: + old, orphan = self._connection, None + self._connection = conn + self._reconnection_exhausted_connection = None + self._connection_generation += 1 if old: log.debug("[control connection] Closing old connection %r, replacing with %r", old, conn) old.close() + if orphan: + log.debug("[control connection] Control connection is shut down, closing new connection %r", orphan) + orphan.close() + def _attach_application_session(self, keyspace, session): """Bind application use of the control connection to ``keyspace``. @@ -4384,44 +4582,177 @@ def reconnect(self): if self._is_shutdown: return - self._submit(self._reconnect) + # Collapse attempts that are queued but have not started yet. Without + # this, a burst of errors queues one _reconnect() each, and every one + # of them past the first cancels the reconnection handler the previous + # one installed and restarts its backoff schedule. + # + # An in-flight reconnection handler is already retrying on its own + # schedule, and _reconnect() would cancel it and restart that schedule + # from its initial delay. The check lives here rather than in the + # callers so that it covers every entry point: return_connection() is + # driven by the heartbeat and fires once per idle_heartbeat_interval + # for as long as the control connection stays defunct, which would + # otherwise reset the backoff on every pass and stop it ever growing. + # The lock is an RLock, so callers already holding it re-enter safely. + with self._reconnection_lock: + if self._reconnection_handler is not None: + handler = self._reconnection_handler + if handler._is_running: + handler._reconnect_requested = True + + # A handler can have been started proactively while the + # current connection was healthy. If that connection fails + # during the handler's backoff, the remaining proactive retry + # still goes first, but exhaustion must hand this independent + # failure a fresh schedule. Record it once so recurring + # heartbeats for an already-failed connection do not extend a + # finite schedule indefinitely. + connection = self._connection + if (handler._failed_connection is None and + connection is not None and + (connection.is_defunct or connection.is_closed)): + handler._failed_connection = connection + handler._new_failure_detected = True + log.debug("[control connection] Reconnection already in progress, " + "not starting another one") + return + if self._reconnect_pending: + log.debug("[control connection] A reconnection attempt is " + "already queued") + # Do not discard this trigger. The pending attempt may fail + # before it installs a handler that owns future retries. + self._reconnect_requested = True + return + pending_seq = self._raise_reconnect_pending() + + submitted = None + try: + submitted = self._submit(self._reconnect) + finally: + if submitted is None: + # Nothing was queued, so nothing will clear the flag. This has + # to hold even when the submission raised, or no further + # reconnection would ever be attempted. + self._clear_reconnect_pending(pending_seq) + + def _raise_reconnect_pending(self): + """ + Mark a reconnection attempt as pending and return a token identifying + it. Only the holder of the newest token may clear the flag again. + """ + with self._reconnection_lock: + self._reconnect_pending = True + self._reconnect_pending_seq += 1 + return self._reconnect_pending_seq + + def _clear_reconnect_pending(self, pending_seq): + """ + Clear the pending flag, unless a newer attempt has claimed it since + `pending_seq` was handed out -- that attempt is the one the flag now + stands for, and dropping it would let a burst of errors queue several + concurrent reconnects. + """ + with self._reconnection_lock: + if self._reconnect_pending_seq == pending_seq: + self._reconnect_pending = False + self._reconnect_requested = False + + def _finish_reconnect(self, pending_seq): + """ + Finish an active attempt, preserving any trigger it collapsed unless + a connection or reconnection handler now owns future work. + """ + follow_up_seq = None + with self._reconnection_lock: + if self._reconnect_pending_seq != pending_seq: + return + + if (self._reconnection_handler is not None or + not self._reconnect_requested): + self._reconnect_pending = False + self._reconnect_requested = False + return + + # Keep the pending flag raised while handing the retained trigger + # to the executor, so another burst still collapses into this one. + self._reconnect_requested = False + self._reconnect_pending_seq += 1 + follow_up_seq = self._reconnect_pending_seq + + submitted = None + try: + submitted = self._submit(self._reconnect) + finally: + if submitted is None: + self._clear_reconnect_pending(follow_up_seq) def _reconnect(self): + # _reconnect_pending stays set for as long as this attempt runs. + # _reconnect_internal() walks the whole query plan twice with a DNS + # re-resolution in between, which routinely outlasts + # idle_heartbeat_interval when the cluster is unreachable; clearing the + # flag here would let every heartbeat in that window queue another + # attempt, each one cancelling the handler the previous one installed + # and restarting its backoff from the initial delay. + pending_seq = self._raise_reconnect_pending() + + with self._lock: + generation = self._connection_generation + log.debug("[control connection] Attempting to reconnect") try: self._set_new_connection(self._reconnect_internal()) - except NoHostAvailable: + except (NoHostAvailable, UnresolvableContactPoints): # make a retry schedule (which includes backoff) schedule = self._cluster.reconnection_policy.new_schedule() with self._reconnection_lock: + with self._lock: + if self._connection_generation != generation: + # An attempt that overlapped this one installed a + # connection while we were failing. Parking a handler + # now would block every later reconnect() for the whole + # backoff and then replace a healthy connection. + log.debug("[control connection] Reconnect failed but " + "another attempt succeeded, not scheduling " + "retries") + return # cancel existing reconnection attempts if self._reconnection_handler: self._reconnection_handler.cancel() # when a connection is successfully made, _set_new_connection - # will be called with the new connection and then our - # _reconnection_handler will be cleared out - self._reconnection_handler = _ControlReconnectionHandler( - self, self._cluster.scheduler, schedule, - self._get_and_set_reconnection_handler, - new_handler=None) - self._reconnection_handler.start() + # will be called with the new connection and will clear out + # our _reconnection_handler + handler = _ControlReconnectionHandler( + self, self._cluster.scheduler, schedule) + self._reconnection_handler = handler + try: + handler.start() + except StopIteration: + # An empty schedule means that the policy permits no + # retries. Treat it like a finite schedule exhausted by a + # failed handler run so heartbeats for the same defunct + # connection do not start it over indefinitely. + handler._release(exhausted=True) + except Exception: + # A handler that never started would sit in the slot + # forever, and reconnect() would take it for one still + # retrying. + if self._reconnection_handler is handler: + self._reconnection_handler = None + raise except Exception: log.debug("[control connection] error reconnecting", exc_info=True) raise - - def _get_and_set_reconnection_handler(self, new_handler): - """ - Called by the _ControlReconnectionHandler when a new connection - is successfully created. Clears out the _reconnection_handler on - this ControlConnection. - """ - with self._reconnection_lock: - old = self._reconnection_handler - self._reconnection_handler = new_handler - return old + finally: + # The attempt is over either way: a connection is installed (the + # flag was already cleared as it went in), a handler is parked and + # collapses later attempts on its own, or a retained trigger queues + # one follow-up after a failure that left no durable retry work. + self._finish_reconnect(pending_seq) def _submit(self, *args, **kwargs): try: @@ -4978,51 +5309,19 @@ def _signal_error(self): if self._is_shutdown: return - # try just signaling the cluster, as this will trigger a reconnect - # as part of marking the host down + # If DOWN handling is dispatched, its control connection callback + # will reconnect. Otherwise reconnect directly. if self._connection and self._connection.is_defunct: connection = self._connection host = self._get_host_for_connection(connection) - # host may be None if it's already been removed, but that indicates - # that errors have already been reported, so we're fine - if host: - original_endpoint = getattr( - connection, 'original_endpoint', None) - unix_backed = ( - isinstance(host.endpoint, UnixSocketEndPoint) or - isinstance(connection.endpoint, UnixSocketEndPoint) or - isinstance(original_endpoint, UnixSocketEndPoint)) - route_mismatch = connection.endpoint != host.endpoint - # Keep ordinary endpoint-equal TCP connections on the - # legacy signal-only path. General suppressed-DOWN recovery - # and its reconnection cadence are outside this change. - if not unix_backed and not route_mismatch: - self._cluster.signal_connection_failure( - host, connection.last_error, - is_host_addition=False) - return - - # A newly resolvable Unix Host or alternate connection - # route still needs the direct reconnect fallback when - # host-state handling suppresses its DOWN notification. A - # fresh DOWN transition guarantees that on_down() will - # enqueue the reconnect instead. - with host.lock: - host_was_up = host.is_up is True - host_was_reconnecting = ( - host.is_currently_reconnecting()) - self._cluster.signal_connection_failure( - host, connection.last_error, - is_host_addition=False) - down_notification_queued = ( - host_was_up and not host_was_reconnecting and - host.is_up is False) - - if down_notification_queued: - return + if host and self._cluster.signal_connection_failure( + host, connection.last_error, + is_host_addition=False): + return - # if the connection is not defunct or the host already left, reconnect - # manually + # If the connection is not defunct, the host is unresolved, or DOWN + # handling was suppressed, reconnect manually. reconnect() leaves an + # in-flight reconnection handler alone on its own schedule. self.reconnect() def on_up(self, host): @@ -5031,12 +5330,13 @@ def on_up(self, host): def on_down(self, host): conn = self._connection - if self._connection_matches_host(conn, host) and \ - self._reconnection_handler is None: - log.debug("[control connection] Control connection host (%s) is " - "considered down, starting reconnection", host) - # this will result in a task being submitted to the executor to reconnect - self.reconnect() + if not self._connection_matches_host(conn, host): + return + + log.debug("[control connection] Control connection host (%s) is " + "considered down, starting reconnection", host) + # this will result in a task being submitted to the executor to reconnect + self.reconnect() def on_add(self, host, refresh_nodes=True): if refresh_nodes: @@ -5057,6 +5357,11 @@ def get_connections(self): def return_connection(self, connection): if connection is self._connection and (connection.is_defunct or connection.is_closed): + with self._reconnection_lock: + if connection is self._reconnection_exhausted_connection: + log.debug("[control connection] Reconnection schedule is " + "exhausted for the defunct connection") + return self.reconnect() diff --git a/cassandra/pool.py b/cassandra/pool.py index 8cc22018d2..d4a11009c6 100644 --- a/cassandra/pool.py +++ b/cassandra/pool.py @@ -267,6 +267,11 @@ class _ReconnectionHandler(object): _cancelled = False + # Whether on_reconnection() keeps the connection it is handed. A handler + # that only uses it to probe the host leaves this False and run() closes + # the connection for it. + _keeps_connection = False + def __init__(self, scheduler, schedule, callback, *callback_args, **callback_kwargs): self.scheduler = scheduler self.schedule = schedule @@ -287,6 +292,7 @@ def run(self): return conn = None + handed_off = False try: conn = self.try_reconnect() except Exception as exc: @@ -306,10 +312,16 @@ def run(self): self.scheduler.schedule(next_delay, self.run) else: if not self._cancelled: + # Mark the handoff before it happens: on_reconnection() adopts + # the connection and may then raise (installing the new control + # connection closes the old one, which runs user callbacks). If + # the flag were set afterwards, that raise would leave us + # closing a connection the subclass is already using. + handed_off = self._keeps_connection self.on_reconnection(conn) self.callback(*(self.callback_args), **(self.callback_kwargs)) finally: - if conn: + if conn and not handed_off: conn.close() def cancel(self): diff --git a/tests/unit/test_cluster.py b/tests/unit/test_cluster.py index 4792083817..9f6dfb5403 100644 --- a/tests/unit/test_cluster.py +++ b/tests/unit/test_cluster.py @@ -1204,6 +1204,265 @@ def test_set_keyspace_for_all_pools_reports_all_errors(self, *_): callback.assert_called_once() assert callback.call_args.args[0] == {'host1': [keyspace_error]} + +class ClusterDownHandlingTest(unittest.TestCase): + + def setUp(self): + self.cluster = Cluster(contact_points=[]) + self.addCleanup(self.cluster.shutdown) + self.cluster.profile_manager.distance = Mock( + return_value=HostDistance.LOCAL) + # The real method reports whether the executor accepted the work. + self.cluster.on_down_potentially_blocking = Mock(return_value=True) + self.cluster._restart_reconnector = Mock(return_value=None) + self.host = Host( + "127.0.0.1", SimpleConvictionPolicy, host_id=uuid.uuid4()) + self.host.set_up() + + def test_down_handling_reaches_control_connection_despite_a_raising_policy(self): + # signal_connection_failure() reports DOWN handling as dispatched, and + # the control connection relies on that to mean its own reconnection + # will be started. A user policy raising must not be what prevents it. + del self.cluster.on_down_potentially_blocking # use the real method + self.cluster.profile_manager.on_down = Mock( + side_effect=RuntimeError('load balancing policy failed')) + self.cluster.control_connection = Mock() + self.cluster._start_reconnector = Mock() + # The method runs in the executor; call the body directly so nothing + # is swallowed by the future. + body = Cluster.on_down_potentially_blocking.__wrapped__ + + body(self.cluster, self.host, is_host_addition=False, + down_event_generation=self.host._down_event_generation) + + self.cluster.control_connection.on_down.assert_called_once_with( + self.host) + self.cluster._start_reconnector.assert_called_once() + + def test_down_handling_continues_when_the_control_connection_raises(self): + # The control connection goes first, so a failure there -- the executor + # rejecting its reconnect submission, say -- must not skip the pool and + # listener work or the host reconnector that follows it. + del self.cluster.on_down_potentially_blocking # use the real method + self.cluster.control_connection = Mock() + self.cluster.control_connection.on_down.side_effect = RuntimeError( + 'cannot schedule new futures after shutdown') + self.cluster._start_reconnector = Mock() + self.cluster.profile_manager.on_down = Mock() + body = Cluster.on_down_potentially_blocking.__wrapped__ + + body(self.cluster, self.host, is_host_addition=False, + down_event_generation=self.host._down_event_generation) + + self.cluster.profile_manager.on_down.assert_called_once_with(self.host) + self.cluster._start_reconnector.assert_called_once_with( + self.host, False) + + def test_down_handling_continues_when_a_session_raises(self): + del self.cluster.on_down_potentially_blocking # use the real method + failing_session = Mock() + failing_session.on_down.side_effect = RuntimeError( + 'session down handling failed') + following_session = Mock() + self.cluster.sessions = [failing_session, following_session] + self.cluster._start_reconnector = Mock() + body = Cluster.on_down_potentially_blocking.__wrapped__ + + body(self.cluster, self.host, is_host_addition=False, + down_event_generation=self.host._down_event_generation) + + following_session.on_down.assert_called_once_with(self.host) + self.cluster._start_reconnector.assert_called_once_with( + self.host, False) + + def test_down_handling_continues_when_a_listener_raises(self): + del self.cluster.on_down_potentially_blocking # use the real method + failing_listener = Mock() + failing_listener.on_down.side_effect = RuntimeError( + 'listener down handling failed') + following_listener = Mock() + self.cluster._listeners = {failing_listener, following_listener} + self.cluster._start_reconnector = Mock() + body = Cluster.on_down_potentially_blocking.__wrapped__ + + body(self.cluster, self.host, is_host_addition=False, + down_event_generation=self.host._down_event_generation) + + following_listener.on_down.assert_called_once_with(self.host) + self.cluster._start_reconnector.assert_called_once_with( + self.host, False) + + def test_signal_connection_failure_rejected_by_conviction_policy(self): + error = ConnectionException("connection failed") + self.host.signal_connection_failure = Mock(return_value=False) + self.cluster.on_down = Mock(return_value=True) + + assert not self.cluster.signal_connection_failure( + self.host, error, is_host_addition=False) + + self.host.signal_connection_failure.assert_called_once_with(error) + self.cluster.on_down.assert_not_called() + + def test_signal_connection_failure_returns_down_handling_result(self): + error = ConnectionException("connection failed") + self.host.signal_connection_failure = Mock(return_value=True) + self.cluster.on_down = Mock(return_value=False) + + assert not self.cluster.signal_connection_failure( + self.host, error, is_host_addition=True, + expect_host_to_be_down=True) + + self.cluster.on_down.assert_called_once_with( + self.host, True, True) + + def test_on_down_dispatches_normal_up_to_down_transition(self): + assert self.cluster.on_down(self.host, is_host_addition=False) + + assert self.host.is_up is False + self.cluster.on_down_potentially_blocking.assert_called_once_with( + self.host, False, self.host._down_event_generation) + + def test_on_down_discounted_when_any_session_has_an_open_pool(self): + missing_pool = Mock() + missing_pool.get_pool_state.return_value = {} + closed_pool = Mock() + closed_pool.get_pool_state.return_value = { + self.host: {'open_count': 0}} + open_pool = Mock() + open_pool.get_pool_state.return_value = { + self.host: {'open_count': 1}} + self.cluster.sessions = [missing_pool, closed_pool, open_pool] + + assert not self.cluster.on_down(self.host, is_host_addition=False) + + assert self.host.is_up is True + self.cluster.on_down_potentially_blocking.assert_not_called() + + def test_on_down_not_discounted_when_all_pools_are_missing_or_closed(self): + missing_pool = Mock() + missing_pool.get_pool_state.return_value = {} + closed_pool = Mock() + closed_pool.get_pool_state.return_value = { + self.host: {'open_count': 0}} + self.cluster.sessions = [missing_pool, closed_pool] + + assert self.cluster.on_down(self.host, is_host_addition=False) + + self.cluster.on_down_potentially_blocking.assert_called_once_with( + self.host, False, self.host._down_event_generation) + + def test_on_down_open_pool_discount_can_be_disabled(self): + open_pool = Mock() + open_pool.get_pool_state.return_value = { + self.host: {'open_count': 1}} + self.cluster.sessions = [open_pool] + self.cluster._discount_down_events = False + + assert self.cluster.on_down(self.host, is_host_addition=False) + + self.cluster.on_down_potentially_blocking.assert_called_once_with( + self.host, False, self.host._down_event_generation) + + def test_on_down_does_not_discount_ignored_host(self): + open_pool = Mock() + open_pool.get_pool_state.return_value = { + self.host: {'open_count': 1}} + self.cluster.sessions = [open_pool] + self.cluster.profile_manager.distance.return_value = \ + HostDistance.IGNORED + + assert self.cluster.on_down(self.host, is_host_addition=False) + + self.cluster.on_down_potentially_blocking.assert_called_once_with( + self.host, False, self.host._down_event_generation) + + def test_on_down_skipped_during_shutdown(self): + self.cluster.is_shutdown = True + + try: + assert not self.cluster.on_down( + self.host, is_host_addition=False) + finally: + # Keep the real shutdown cleanup effective for this test cluster. + self.cluster.is_shutdown = False + + assert self.host.is_up is True + self.cluster.on_down_potentially_blocking.assert_not_called() + + def test_on_down_skipped_when_pool_creation_is_disabled(self): + self.cluster.allow_control_connection_query_fallback = \ + ControlConnectionQueryFallback.SkipPoolCreation + + assert not self.cluster.on_down(self.host, is_host_addition=False) + + assert self.host.is_up is True + self.cluster.on_down_potentially_blocking.assert_not_called() + + def test_on_down_restarts_already_down_or_uninitialized_host(self): + for initial_state in (False, None): + with self.subTest(initial_state=initial_state): + self.host.is_up = initial_state + self.host._currently_handling_node_down = False + self.cluster.on_down_potentially_blocking.reset_mock() + self.cluster._restart_reconnector.reset_mock() + + assert not self.cluster.on_down( + self.host, is_host_addition=False) + + assert self.host.is_up is False + self.cluster.on_down_potentially_blocking.assert_not_called() + self.cluster._restart_reconnector.assert_called_once_with( + self.host, False, self.host._down_event_generation) + + def test_on_down_expected_down_host_dispatches_recovery(self): + for initial_state in (False, None): + with self.subTest(initial_state=initial_state): + self.host.is_up = initial_state + self.host._currently_handling_node_down = False + self.cluster.on_down_potentially_blocking.reset_mock() + + assert self.cluster.on_down( + self.host, is_host_addition=True, + expect_host_to_be_down=True) + + self.cluster.on_down_potentially_blocking \ + .assert_called_once_with( + self.host, True, self.host._down_event_generation) + + def test_on_down_dispatches_transition_with_existing_reconnector(self): + self.host.get_and_set_reconnection_handler(Mock()) + + assert self.cluster.on_down(self.host, is_host_addition=False) + + assert self.host.is_up is False + self.cluster.on_down_potentially_blocking.assert_called_once_with( + self.host, False, self.host._down_event_generation) + + def test_on_down_reports_undispatched_when_the_executor_rejects_it(self): + # Restore the real method so the executor submission is exercised. + del self.cluster.on_down_potentially_blocking + real_executor = self.cluster.executor + self.addCleanup(setattr, self.cluster, 'executor', real_executor) + self.cluster.executor = Mock() + self.cluster.executor.submit.side_effect = RuntimeError( + "cannot schedule new futures") + + assert not self.cluster.on_down(self.host, is_host_addition=False) + + assert self.host.is_up is False + self.cluster.executor.submit.assert_called_once() + + def test_on_down_reports_dispatched_when_the_executor_accepts_it(self): + del self.cluster.on_down_potentially_blocking + real_executor = self.cluster.executor + self.addCleanup(setattr, self.cluster, 'executor', real_executor) + self.cluster.executor = Mock() + + assert self.cluster.on_down(self.host, is_host_addition=False) + + self.cluster.executor.submit.assert_called_once() + + class ProtocolVersionTests(unittest.TestCase): def test_protocol_downgrade_test(self): diff --git a/tests/unit/test_control_connection.py b/tests/unit/test_control_connection.py index 13a68c7e55..190aee1b18 100644 --- a/tests/unit/test_control_connection.py +++ b/tests/unit/test_control_connection.py @@ -12,21 +12,30 @@ # See the License for the specific language governing permissions and # limitations under the License. +import gc import unittest +import weakref from concurrent.futures import ThreadPoolExecutor +from threading import Event, Thread from unittest.mock import Mock, ANY, call, patch -from cassandra import OperationTimedOut, SchemaTargetType, SchemaChangeType +from cassandra import (AuthenticationFailed, OperationTimedOut, + SchemaTargetType, SchemaChangeType, + UnresolvableContactPoints) from cassandra.protocol import ResultMessage, RESULT_KIND_ROWS from cassandra.cluster import (Cluster, ControlConnection, _Scheduler, ProfileManager, EXEC_PROFILE_DEFAULT, - ExecutionProfile) -from cassandra.pool import Host + ExecutionProfile, + ControlConnectionQueryFallback, + NoHostAvailable, _ControlReconnectionHandler) +from cassandra.pool import Host, _ReconnectionHandler from cassandra.connection import (ConnectionException, EndPoint, DefaultEndPoint, DefaultEndPointFactory, UnixSocketEndPoint) -from cassandra.policies import (SimpleConvictionPolicy, RoundRobinPolicy, - ConstantReconnectionPolicy, IdentityTranslator) +from cassandra.policies import (HostDistance, SimpleConvictionPolicy, + RoundRobinPolicy, ConstantReconnectionPolicy, + ExponentialReconnectionPolicy, + IdentityTranslator) PEER_IP = "foobar" @@ -149,6 +158,7 @@ def _node_meta_results(local_results, peer_results): class MockConnection(object): is_defunct = False + is_closed = False def __init__(self): self.endpoint = DefaultEndPoint("192.168.1.0") @@ -230,6 +240,29 @@ def _refresh_control_connection_over_network(self): self.connection.original_endpoint = self.connection.endpoint self.control_connection.refresh_node_list_and_token_map() + def _use_cluster_down_handling( + self, sessions=(), + fallback=ControlConnectionQueryFallback.Disabled): + self.cluster.sessions = list(sessions) + self.cluster._discount_down_events = True + self.cluster.allow_control_connection_query_fallback = fallback + self.cluster.profile_manager = Mock() + self.cluster.profile_manager.distance.return_value = \ + HostDistance.LOCAL + # The real method reports whether the executor accepted the work. + self.cluster.on_down_potentially_blocking = Mock(return_value=True) + self.cluster._restart_reconnector = Mock(return_value=True) + self.cluster.on_down = Cluster.on_down.__get__(self.cluster) + self.cluster.signal_connection_failure = \ + Cluster.signal_connection_failure.__get__(self.cluster) + + def _discount_down_for(self, host): + """Model a session pool that keeps ``host`` up despite a conviction.""" + session = Mock() + session.get_pool_state.return_value = {host: {'open_count': 1}} + self._use_cluster_down_handling([session]) + return session + def test_wait_for_schema_agreement(self): """ Basic test with all schema versions agreeing @@ -469,6 +502,804 @@ def test_schema_query_uses_shard_aware_connection_original_endpoint(self): assert query == self.control_connection._SELECT_SCHEMA_PEERS_TEMPLATE \ .format(nt_col_name='rpc_address') + def test_defunct_tcp_control_reconnects_when_open_pool_discounts_down(self): + host = self.cluster.metadata.get_host_by_host_id('uuid1') + host.set_up() + session = Mock() + session.get_pool_state.return_value = { + host: {'open_count': 1}} + self._use_cluster_down_handling([session]) + self.connection.is_defunct = True + self.connection.last_error = ConnectionException( + 'control connection failed') + self.cluster.executor.reset_mock() + + self.control_connection._signal_error() + + assert host.is_up is True + self.cluster.on_down_potentially_blocking.assert_not_called() + self.cluster.executor.submit.assert_called_once_with( + self.control_connection._reconnect) + + def test_defunct_control_reconnects_when_conviction_is_rejected(self): + host = self.cluster.metadata.get_host_by_host_id('uuid1') + host.set_up() + self._use_cluster_down_handling() + self.connection.is_defunct = True + self.connection.last_error = OperationTimedOut() + self.cluster.executor.reset_mock() + + self.control_connection._signal_error() + + assert host.is_up is True + self.cluster.on_down_potentially_blocking.assert_not_called() + self.cluster.executor.submit.assert_called_once_with( + self.control_connection._reconnect) + + def test_defunct_control_reconnects_when_host_is_already_down(self): + host = self.cluster.metadata.get_host_by_host_id('uuid1') + host.set_down() + self._use_cluster_down_handling() + self.connection.is_defunct = True + self.connection.last_error = ConnectionException( + 'control connection failed') + self.cluster.executor.reset_mock() + + self.control_connection._signal_error() + + self.cluster.on_down_potentially_blocking.assert_not_called() + self.cluster.executor.submit.assert_called_once_with( + self.control_connection._reconnect) + + def test_defunct_control_reconnects_when_host_reconnector_is_active(self): + host = self.cluster.metadata.get_host_by_host_id('uuid1') + host.set_down() + host.get_and_set_reconnection_handler(Mock()) + self._use_cluster_down_handling() + self.connection.is_defunct = True + self.connection.last_error = ConnectionException( + 'control connection failed') + self.cluster.executor.reset_mock() + + self.control_connection._signal_error() + + assert host.is_up is False + self.cluster.on_down_potentially_blocking.assert_not_called() + self.cluster.executor.submit.assert_called_once_with( + self.control_connection._reconnect) + + def test_defunct_control_reconnects_when_pool_creation_is_disabled(self): + host = self.cluster.metadata.get_host_by_host_id('uuid1') + host.set_up() + self._use_cluster_down_handling( + fallback=ControlConnectionQueryFallback.SkipPoolCreation) + self.connection.is_defunct = True + self.connection.last_error = ConnectionException( + 'control connection failed') + self.cluster.executor.reset_mock() + + self.control_connection._signal_error() + + assert host.is_up is True + self.cluster.on_down_potentially_blocking.assert_not_called() + self.cluster.executor.submit.assert_called_once_with( + self.control_connection._reconnect) + + def test_defunct_control_waits_for_dispatched_down_callback(self): + host = self.cluster.metadata.get_host_by_host_id('uuid1') + host.set_up() + self._use_cluster_down_handling() + self.connection.is_defunct = True + self.connection.last_error = ConnectionException( + 'control connection failed') + self.cluster.executor.reset_mock() + + self.control_connection._signal_error() + + assert host.is_up is False + self.cluster.on_down_potentially_blocking.assert_called_once_with( + host, False, host._down_event_generation) + self.cluster.executor.submit.assert_not_called() + + self.control_connection.on_down(host) + + self.cluster.executor.submit.assert_called_once_with( + self.control_connection._reconnect) + + def test_signal_error_reconnects_non_defunct_connection(self): + self.connection.is_defunct = False + self.cluster.executor.reset_mock() + + self.control_connection._signal_error() + + self.cluster.executor.submit.assert_called_once_with( + self.control_connection._reconnect) + + def test_signal_error_reconnects_when_host_is_unresolved(self): + self._forget_local_host() + self.connection.is_defunct = True + self.connection.last_error = ConnectionException( + 'control connection failed') + self.cluster.executor.reset_mock() + + self.control_connection._signal_error() + + self.cluster.executor.submit.assert_called_once_with( + self.control_connection._reconnect) + + def test_signal_error_does_nothing_after_control_connection_shutdown(self): + self.control_connection._is_shutdown = True + self.connection.is_defunct = True + self.cluster.executor.reset_mock() + + self.control_connection._signal_error() + + self.cluster.executor.submit.assert_not_called() + + def test_signal_error_leaves_an_in_flight_reconnection_alone(self): + # _reconnect() cancels the handler and restarts its schedule from the + # initial delay, so repeated errors must not keep resetting the backoff. + host = self.cluster.metadata.get_host_by_host_id('uuid1') + host.set_down() + self._use_cluster_down_handling() + self.control_connection._reconnection_handler = Mock() + self.connection.is_defunct = True + self.connection.last_error = ConnectionException( + 'control connection failed') + self.cluster.executor.reset_mock() + + self.control_connection._signal_error() + + self.cluster.executor.submit.assert_not_called() + + def _make_reconnection_handler(self): + handler = _ControlReconnectionHandler( + self.control_connection, self.cluster.scheduler, iter([1.0])) + self.control_connection._reconnection_handler = handler + return handler + + def test_reconnection_handler_releases_its_slot_when_it_gives_up(self): + handler = self._make_reconnection_handler() + + handler.on_exception(ConnectionException('refused'), None) + + assert self.control_connection._reconnection_handler is None + + def test_reconnection_handler_keeps_its_slot_while_it_retries(self): + for exc in (ConnectionException('refused'), + AuthenticationFailed('bad password')): + with self.subTest(exc=exc): + handler = self._make_reconnection_handler() + + assert handler.on_exception(exc, 1.0) + + assert self.control_connection._reconnection_handler is handler + + def test_reconnection_handler_never_releases_a_replacement(self): + handler = self._make_reconnection_handler() + replacement = self._make_reconnection_handler() + + handler.on_exception(ConnectionException('refused'), None) + + assert self.control_connection._reconnection_handler is replacement + + def test_reconnection_handler_run_leaves_a_replacement_in_the_slot(self): + # A finishing handler must not clear a replacement that another thread + # parked in the slot while it was handing its connection over. + # _set_new_connection() has already released this handler by then, so + # clearing again can only evict someone else -- and it would do so + # without cancelling them, leaving a handler that keeps retrying where + # reconnect() can no longer see it. + handler = self._make_reconnection_handler() + self.control_connection._connection = None + conn = Mock() + replacement = [] + + def install_and_lose_the_race(new_conn): + # What _set_new_connection() really does -- release the slot and + # adopt the connection -- plus another thread winning the race to + # park a fresh handler in the slot it just emptied. + self.control_connection._reconnection_handler = None + self.control_connection._connection = new_conn + replacement.append(self._make_reconnection_handler()) + + with patch.object(handler, 'try_reconnect', return_value=conn), \ + patch.object(self.control_connection, '_set_new_connection', + side_effect=install_and_lose_the_race): + handler.run() + + assert self.control_connection._reconnection_handler is replacement[0] + + def test_reconnection_handler_keeps_the_connection_it_installs(self): + # run() closes the connection it opened, which is right for the host + # handler that only probes with it, but this one hands it to the + # control connection. + handler = self._make_reconnection_handler() + self.control_connection._connection = None + conn = Mock() + + with patch.object(handler, 'try_reconnect', return_value=conn): + handler.run() + + assert self.control_connection._connection is conn + conn.close.assert_not_called() + + def test_reconnection_handler_keeps_the_connection_a_failed_install_took(self): + # Installing the new control connection closes the old one, which runs + # user callbacks, and one of those may raise. The connection is already + # adopted by then, so it must not be closed on the way out. + handler = self._make_reconnection_handler() + self.control_connection._connection = None + conn = Mock() + + with patch.object(handler, 'try_reconnect', return_value=conn), \ + patch.object(self.control_connection, '_set_new_connection', + side_effect=RuntimeError('listener failed')): + with self.assertRaises(RuntimeError): + handler.run() + + conn.close.assert_not_called() + + def test_reconnection_handler_closes_a_connection_it_only_probes_with(self): + # The plain handler, like the host one, only uses the connection to + # prove the host is reachable, so run() still closes it for it. + handler = _ReconnectionHandler(self.cluster.scheduler, iter([1.0]), + Mock()) + conn = Mock() + + with patch.object(handler, 'try_reconnect', return_value=conn): + handler.run() + + conn.close.assert_called_once_with() + + def test_reconnecting_successfully_releases_a_parked_handler(self): + # A handler installed by an earlier failure is still backing off when + # an unrelated reconnect succeeds. Left in the slot it would look like + # a reconnection in progress to every later error. + handler = self._make_reconnection_handler() + self.control_connection._connection = None + + with patch.object(self.control_connection, '_reconnect_internal', + return_value=Mock()): + self.control_connection._reconnect() + + assert self.control_connection._reconnection_handler is None + assert handler._cancelled + + def test_set_new_connection_closes_a_connection_shutdown_beat(self): + # shutdown() already closed the control connection, so nothing would + # ever use this one or close it on our behalf. + self.control_connection._is_shutdown = True + conn = Mock() + + self.control_connection._set_new_connection(conn) + + conn.close.assert_called_once_with() + assert self.control_connection._connection is self.connection + + def test_signal_error_reconnects_once_a_reconnection_has_given_up(self): + host = self.cluster.metadata.get_host_by_host_id('uuid1') + host.set_down() + self._use_cluster_down_handling() + handler = self._make_reconnection_handler() + handler.on_exception(ConnectionException('refused'), None) + self.connection.is_defunct = True + self.connection.last_error = ConnectionException( + 'control connection failed') + self.cluster.executor.reset_mock() + + self.control_connection._signal_error() + + self.cluster.executor.submit.assert_called_once_with( + self.control_connection._reconnect) + + def test_reconnect_collapses_an_attempt_that_has_not_started(self): + self.cluster.executor.reset_mock() + + self.control_connection.reconnect() + self.control_connection.reconnect() + + self.cluster.executor.submit.assert_called_once_with( + self.control_connection._reconnect) + + def test_reconnect_is_queued_again_once_the_attempt_starts(self): + self.cluster.executor.reset_mock() + self.control_connection.reconnect() + self.control_connection._connection = None + + with patch.object(self.control_connection, '_reconnect_internal', + return_value=Mock()): + self.control_connection._reconnect() + + self.control_connection.reconnect() + + assert self.cluster.executor.submit.call_args_list == [ + call(self.control_connection._reconnect), + call(self.control_connection._reconnect)] + + def test_reconnect_does_not_clear_a_flag_a_newer_attempt_claimed(self): + # _set_new_connection() drops the pending flag as the connection goes + # in. If that connection errors before the installing attempt returns, + # the reconnect() it triggers claims the flag, and the finishing + # attempt must not clear it -- a further trigger would then queue a + # second attempt that runs alongside the first. + self.cluster.executor.reset_mock() + self.control_connection._connection = None + install = self.control_connection._set_new_connection + + def install_then_lose_the_connection(conn): + install(conn) + self.control_connection.reconnect() + + with patch.object(self.control_connection, '_reconnect_internal', + return_value=Mock()), \ + patch.object(self.control_connection, '_set_new_connection', + side_effect=install_then_lose_the_connection): + self.control_connection._reconnect() + + assert self.control_connection._reconnect_pending + self.cluster.executor.submit.assert_called_once_with( + self.control_connection._reconnect) + + self.control_connection.reconnect() + + self.cluster.executor.submit.assert_called_once_with( + self.control_connection._reconnect) + + def test_reconnect_collapses_an_attempt_while_one_is_running(self): + # _reconnect_internal() walks the whole query plan and can outlast the + # idle heartbeat, which calls reconnect() through return_connection(). + # Those calls must not queue a second attempt that would cancel the + # first one's handler and restart its backoff. + def reconnect_while_running(): + self.cluster.executor.reset_mock() + self.control_connection.reconnect() + raise NoHostAvailable('no host', {}) + + with patch.object(self.control_connection, '_reconnect_internal', + side_effect=reconnect_while_running): + self.control_connection._reconnect() + + self.cluster.executor.submit.assert_not_called() + + def test_reconnect_handler_owns_trigger_after_dns_failure(self): + # A trigger arriving during an attempt must not queue duplicate work + # when that attempt leaves a backoff handler owning future retries. + def reconnect_while_running(): + self.cluster.executor.reset_mock() + self.control_connection.reconnect() + raise UnresolvableContactPoints({}) + + with patch.object(self.control_connection, '_reconnect_internal', + side_effect=reconnect_while_running): + self.control_connection._reconnect() + + self.cluster.executor.submit.assert_not_called() + assert self.control_connection._reconnection_handler is not None + assert not self.control_connection._reconnect_pending + + def test_reconnect_preserves_trigger_if_new_handler_already_exhausted(self): + # A zero-delay finite handler can exhaust on another worker before the + # attempt that started it reaches _finish_reconnect(). It no longer + # owns retries at that point, so a trigger collapsed in between must + # be submitted as follow-up work. + self.cluster.reconnection_policy = ConstantReconnectionPolicy( + 0, max_attempts=1) + self.connection.is_defunct = True + + def exhaust_then_trigger(_delay, run): + run() + self.control_connection.reconnect() + + self.cluster.scheduler.schedule.side_effect = exhaust_then_trigger + self.cluster.executor.reset_mock() + + with patch.object(self.control_connection, '_reconnect_internal', + side_effect=NoHostAvailable('no host', {})): + self.control_connection._reconnect() + + assert self.control_connection._reconnection_handler is None + assert self.control_connection._reconnect_pending + self.cluster.executor.submit.assert_called_once_with( + self.control_connection._reconnect) + + def test_reconnect_retries_when_contact_points_cannot_resolve(self): + # A DNS failure leaves no connection that can trigger a heartbeat, so + # it must enter the normal reconnection backoff on its own. + with patch.object(self.control_connection, '_reconnect_internal', + side_effect=UnresolvableContactPoints({})): + self.control_connection._reconnect() + + assert self.control_connection._reconnection_handler is not None + + def test_connect_does_not_raise_when_shutdown_beats_it(self): + # shutdown() ran while _reconnect_internal() was connecting, so + # _set_new_connection() declined to install anything. + self.control_connection._connection = None + self.cluster.protocol_version = 4 + + def shut_down_and_connect(): + self.control_connection._is_shutdown = True + return Mock() + + with patch.object(self.control_connection, '_reconnect_internal', + side_effect=shut_down_and_connect): + self.control_connection.connect() + + assert self.control_connection._connection is None + + def test_reconnect_does_not_park_a_handler_after_another_attempt_won(self): + # Two attempts can overlap: reconnect() only collapses ones that have + # not started. If this one fails after the other installed a live + # connection, a handler parked here would block every later + # reconnect() for the whole backoff and then replace that connection. + def lose_the_race(): + self.control_connection._connection_generation += 1 + raise NoHostAvailable('no host', {}) + + with patch.object(self.control_connection, '_reconnect_internal', + side_effect=lose_the_race): + self.control_connection._reconnect() + + assert self.control_connection._reconnection_handler is None + + def test_reconnect_parks_a_handler_when_no_attempt_won(self): + with patch.object(self.control_connection, '_reconnect_internal', + side_effect=NoHostAvailable('no host', {})): + self.control_connection._reconnect() + + assert self.control_connection._reconnection_handler is not None + + def test_reconnection_handler_closes_the_connection_it_cannot_hand_off(self): + # The ControlConnection was collected while the handler was backing + # off. run() has already marked the connection as handed off, so + # nothing else is left to close it. + handler = self._make_reconnection_handler() + owner = Mock() + handler.control_connection = weakref.proxy(owner) + del owner + gc.collect() + conn = Mock() + + with patch.object(handler, 'try_reconnect', return_value=conn): + handler.run() + + conn.close.assert_called_once_with() + + def test_reconnect_defers_to_a_handler_left_by_a_failed_attempt(self): + self.cluster.executor.reset_mock() + self.control_connection.reconnect() + + with patch.object(self.control_connection, '_reconnect_internal', + side_effect=NoHostAvailable('no host', {})): + self.control_connection._reconnect() + + handler = self.control_connection._reconnection_handler + assert handler is not None + + self.control_connection.reconnect() + + # The handler installed by the failed attempt is retrying on its own + # schedule; starting another attempt would cancel it and restart that + # schedule from its initial delay. + self.cluster.executor.submit.assert_called_once_with( + self.control_connection._reconnect) + assert self.control_connection._reconnection_handler is handler + assert not handler._cancelled + + def test_reconnect_is_queued_again_after_a_handler_fails_to_start(self): + # A handler that never got scheduled retries nothing, so it must not + # be left in the slot for reconnect() to defer to forever. + self.cluster.scheduler.schedule.side_effect = RuntimeError( + 'scheduler is shut down') + + with patch.object(self.control_connection, '_reconnect_internal', + side_effect=NoHostAvailable('no host', {})): + with self.assertRaises(RuntimeError): + self.control_connection._reconnect() + + assert self.control_connection._reconnection_handler is None + + self.cluster.executor.reset_mock() + self.control_connection.reconnect() + + self.cluster.executor.submit.assert_called_once_with( + self.control_connection._reconnect) + + def test_returning_a_defunct_connection_does_not_restart_the_backoff(self): + # The heartbeat hands a defunct control connection back once per + # idle_heartbeat_interval for as long as it stays defunct. Each of + # those must leave the parked handler's schedule alone. + self.cluster.executor.reset_mock() + + with patch.object(self.control_connection, '_reconnect_internal', + side_effect=NoHostAvailable('no host', {})): + self.control_connection._reconnect() + + handler = self.control_connection._reconnection_handler + assert handler is not None + self.cluster.executor.reset_mock() + + self.connection.is_defunct = True + for _ in range(3): + self.control_connection.return_connection(self.connection) + + self.cluster.executor.submit.assert_not_called() + assert self.control_connection._reconnection_handler is handler + assert not handler._cancelled + + def test_heartbeat_does_not_restart_an_exhausted_finite_schedule(self): + self.cluster.reconnection_policy = ConstantReconnectionPolicy( + 0, max_attempts=1) + self.connection.is_defunct = True + + with patch.object(self.control_connection, '_reconnect_internal', + side_effect=NoHostAvailable('no host', {})): + self.control_connection._reconnect() + handler = self.control_connection._reconnection_handler + assert handler is not None + + # Run the only scheduled retry. Its failure exhausts the finite + # schedule and releases the handler slot. + handler.run() + + assert self.control_connection._reconnection_handler is None + self.cluster.executor.reset_mock() + self.connection.is_defunct = True + + # ConnectionHeartbeat returns this same defunct connection on every + # interval; none of those passes may create a new retry schedule. + for _ in range(3): + self.control_connection.return_connection(self.connection) + + self.cluster.executor.submit.assert_not_called() + + def test_final_handler_attempt_preserves_heartbeat_trigger(self): + self.cluster.reconnection_policy = ConstantReconnectionPolicy( + 0, max_attempts=1) + self.connection.is_defunct = True + + with patch.object(self.control_connection, '_reconnect_internal', + side_effect=NoHostAvailable('no host', {})): + self.control_connection._reconnect() + + handler = self.control_connection._reconnection_handler + assert handler is not None + self.cluster.executor.reset_mock() + + def fail_after_heartbeat(): + self.control_connection.return_connection(self.connection) + raise NoHostAvailable('no host', {}) + + with patch.object(self.control_connection, '_reconnect_internal', + side_effect=fail_after_heartbeat): + # The only scheduled retry receives a heartbeat trigger while it + # is running and then exhausts the finite schedule. + handler.run() + + assert self.control_connection._reconnection_handler is None + assert self.control_connection._reconnection_exhausted_connection \ + is None + assert self.control_connection._reconnect_pending + self.cluster.executor.submit.assert_called_once_with( + self.control_connection._reconnect) + + def test_nonfinal_handler_attempt_consumes_heartbeat_trigger(self): + self.cluster.reconnection_policy = ConstantReconnectionPolicy( + 0, max_attempts=2) + self.connection.is_defunct = True + + with patch.object(self.control_connection, '_reconnect_internal', + side_effect=NoHostAvailable('no host', {})): + self.control_connection._reconnect() + + handler = self.control_connection._reconnection_handler + assert handler is not None + self.cluster.executor.reset_mock() + + def fail_after_heartbeat(): + self.control_connection.return_connection(self.connection) + raise NoHostAvailable('no host', {}) + + with patch.object(self.control_connection, '_reconnect_internal', + side_effect=fail_after_heartbeat): + # This retry still has another scheduled attempt to own the + # heartbeat trigger, so it must not start a new schedule. + handler.run() + + assert self.control_connection._reconnection_handler is handler + self.cluster.executor.submit.assert_not_called() + + with patch.object(self.control_connection, '_reconnect_internal', + side_effect=NoHostAvailable('no host', {})): + handler.run() + + assert self.control_connection._reconnection_handler is None + assert self.control_connection._reconnection_exhausted_connection \ + is self.connection + self.cluster.executor.submit.assert_not_called() + + def test_older_handler_run_does_not_clear_overlapping_retry_state(self): + self.connection.is_defunct = True + handler = _ControlReconnectionHandler( + self.control_connection, self.cluster.scheduler, iter([0])) + self.control_connection._reconnection_handler = handler + second_started = Event() + finish_second = Event() + attempts = [0] + retry_thread = [] + + def fail_reconnect(): + attempts[0] += 1 + if attempts[0] == 1: + raise NoHostAvailable('no host', {}) + second_started.set() + finish_second.wait(5) + raise NoHostAvailable('no host', {}) + + def start_retry(_delay, run): + retry_thread.append(Thread(target=run)) + retry_thread[0].start() + assert second_started.wait(5) + + self.cluster.scheduler.schedule.side_effect = start_retry + self.cluster.executor.reset_mock() + + with patch.object(self.control_connection, '_reconnect_internal', + side_effect=fail_reconnect): + handler.run() + try: + # The zero-delay final retry started before the first run's + # finally block. The older run must leave its active state set + # so this heartbeat trigger is preserved if the retry fails. + assert handler._is_running + self.control_connection.return_connection(self.connection) + finally: + finish_second.set() + retry_thread[0].join(5) + + assert not retry_thread[0].is_alive() + assert self.control_connection._reconnection_handler is None + assert self.control_connection._reconnection_exhausted_connection \ + is None + assert self.control_connection._reconnect_pending + self.cluster.executor.submit.assert_called_once_with( + self.control_connection._reconnect) + + def test_heartbeat_does_not_restart_an_empty_reconnection_schedule(self): + self.cluster.reconnection_policy = ExponentialReconnectionPolicy( + 1.0, 2.0, max_attempts=0) + self.connection.is_defunct = True + + with patch.object(self.control_connection, '_reconnect_internal', + side_effect=NoHostAvailable('no host', {})): + self.control_connection._reconnect() + + assert self.control_connection._reconnection_handler is None + assert self.control_connection._reconnection_exhausted_connection \ + is self.connection + self.cluster.executor.reset_mock() + + # No retries means that recurring heartbeat returns must not turn the + # empty schedule into one fresh immediate attempt per interval. + for _ in range(3): + self.control_connection.return_connection(self.connection) + + self.cluster.executor.submit.assert_not_called() + + def test_exhausted_replacement_does_not_suppress_a_later_failure(self): + self.cluster.reconnection_policy = ConstantReconnectionPolicy( + 0, max_attempts=1) + host = self.cluster.metadata.get_host_by_host_id('uuid1') + + # Removing the connected host starts a proactive replacement while + # the existing control connection is still healthy. + self.control_connection.on_remove(host) + with patch.object(self.control_connection, '_reconnect_internal', + side_effect=NoHostAvailable('no host', {})): + self.control_connection._reconnect() + handler = self.control_connection._reconnection_handler + assert handler is not None + handler.run() + + assert self.control_connection._reconnection_handler is None + assert self.control_connection._reconnection_exhausted_connection \ + is None + + # A later, independent failure of the old connection must get a new + # retry schedule rather than being mistaken for the exhausted one. + self.cluster.executor.reset_mock() + self.connection.is_defunct = True + self.control_connection.return_connection(self.connection) + + self.cluster.executor.submit.assert_called_once_with( + self.control_connection._reconnect) + + def test_failure_between_proactive_retries_starts_a_fresh_schedule(self): + self.cluster.reconnection_policy = ConstantReconnectionPolicy( + 0, max_attempts=1) + host = self.cluster.metadata.get_host_by_host_id('uuid1') + + # Start a proactive replacement while the existing control connection + # is healthy, then park the handler between its scheduled attempts. + self.control_connection.on_remove(host) + with patch.object(self.control_connection, '_reconnect_internal', + side_effect=NoHostAvailable('no host', {})): + self.control_connection._reconnect() + + handler = self.control_connection._reconnection_handler + assert handler is not None + assert handler._failed_connection is None + self.cluster.executor.reset_mock() + + # With heartbeats disabled, this is the only failure notification the + # connection supplies. The proactive handler must retain it until its + # own finite schedule exhausts, then start a failure-owned schedule. + self.connection.is_defunct = True + self.control_connection.return_connection(self.connection) + self.cluster.executor.submit.assert_not_called() + + with patch.object(self.control_connection, '_reconnect_internal', + side_effect=NoHostAvailable('no host', {})): + handler.run() + + assert self.control_connection._reconnection_handler is None + assert self.control_connection._reconnect_pending + self.cluster.executor.submit.assert_called_once_with( + self.control_connection._reconnect) + + def test_reconnect_is_queued_again_after_a_rejected_submission(self): + self.cluster.executor.reset_mock() + self.cluster.is_shutdown = True + self.addCleanup(setattr, self.cluster, 'is_shutdown', False) + + self.control_connection.reconnect() + + self.cluster.executor.submit.assert_not_called() + + self.cluster.is_shutdown = False + self.control_connection.reconnect() + + self.cluster.executor.submit.assert_called_once_with( + self.control_connection._reconnect) + + def test_reconnect_is_queued_again_after_a_raising_submission(self): + self.cluster.executor.reset_mock() + self.cluster.executor.submit.side_effect = RuntimeError( + 'cannot schedule new futures after shutdown') + + with self.assertRaises(RuntimeError): + self.control_connection.reconnect() + + self.cluster.executor.submit.side_effect = None + self.cluster.executor.reset_mock() + + self.control_connection.reconnect() + + self.cluster.executor.submit.assert_called_once_with( + self.control_connection._reconnect) + + def test_defunct_control_reconnects_when_down_dispatch_is_dropped(self): + # on_down() marks the host down but the executor refuses the DOWN + # callback, so nothing else will reconnect the control connection. + host = self.cluster.metadata.get_host_by_host_id('uuid1') + host.set_up() + self._use_cluster_down_handling() + self.cluster.on_down_potentially_blocking = \ + Cluster.on_down_potentially_blocking.__get__(self.cluster) + self.connection.is_defunct = True + self.connection.last_error = ConnectionException( + 'control connection failed') + self.cluster.executor.reset_mock() + self.cluster.executor.submit.side_effect = [ + RuntimeError('cannot schedule new futures'), Mock()] + + self.control_connection._signal_error() + + assert host.is_up is False + assert self.cluster.executor.submit.call_args_list[-1] == call( + self.control_connection._reconnect) + def test_refresh_network_local_preserves_known_unix_endpoint(self): maintenance_endpoint, local_host = \ self._discover_local_host_over_unix() @@ -481,51 +1312,59 @@ def test_refresh_network_local_preserves_known_unix_endpoint(self): assert host_index[local_host] is not None assert Cluster.get_control_connection_host(self.cluster) is local_host - connection_error = ConnectionException('control connection failed') + # A DOWN transition discounted because a usable session pool remains + # queues no control on_down callback, so the reconnect is direct. + self._discount_down_for(local_host) self.connection.is_defunct = True - self.connection.last_error = connection_error - # Model a conviction whose DOWN transition is discounted because a - # usable session pool remains: no control on_down callback is queued. - self.cluster.signal_connection_failure = Mock(return_value=True) + self.connection.last_error = ConnectionException( + 'control connection failed') self.cluster.executor.reset_mock() self.control_connection._signal_error() - self.cluster.signal_connection_failure.assert_called_once_with( - local_host, connection_error, is_host_addition=False) + assert local_host.is_up is True + self.cluster.on_down_potentially_blocking.assert_not_called() self.cluster.executor.submit.assert_called_once_with( self.control_connection._reconnect) def test_unix_signal_error_reconnects_if_down_notification_suppressed(self): _, local_host = self._discover_local_host_over_unix() - connection_error = ConnectionException('control connection failed') + session = self._discount_down_for(local_host) self.connection.is_defunct = True - self.connection.last_error = connection_error - self.cluster.signal_connection_failure = Mock(return_value=True) + self.connection.last_error = ConnectionException( + 'control connection failed') self.cluster.executor.reset_mock() self.control_connection._signal_error() - self.cluster.signal_connection_failure.assert_called_once_with( - local_host, connection_error, is_host_addition=False) + # The discount is what suppresses the notification here, which means + # the host really was resolved from the Unix endpoint: an unresolved + # host would reconnect without ever consulting a session pool. + session.get_pool_state.assert_called_once_with() + assert local_host.is_up is True + self.cluster.on_down_potentially_blocking.assert_not_called() self.cluster.executor.submit.assert_called_once_with( self.control_connection._reconnect) def test_tcp_route_mismatch_reconnects_if_down_notification_suppressed(self): self.control_connection.refresh_node_list_and_token_map() local_host = self.cluster.metadata.get_host_by_host_id('uuid1') + local_host.set_up() self.connection.endpoint = DefaultEndPoint('192.168.1.0', 19042) self.connection.original_endpoint = local_host.endpoint - connection_error = ConnectionException('control connection failed') + session = self._discount_down_for(local_host) self.connection.is_defunct = True - self.connection.last_error = connection_error - self.cluster.signal_connection_failure = Mock(return_value=True) + self.connection.last_error = ConnectionException( + 'control connection failed') self.cluster.executor.reset_mock() self.control_connection._signal_error() - self.cluster.signal_connection_failure.assert_called_once_with( - local_host, connection_error, is_host_addition=False) + # As above: reaching the discount proves the host was resolved over + # the original endpoint despite the connection's route mismatch. + session.get_pool_state.assert_called_once_with() + assert local_host.is_up is True + self.cluster.on_down_potentially_blocking.assert_not_called() self.cluster.executor.submit.assert_called_once_with( self.control_connection._reconnect) @@ -561,39 +1400,33 @@ def test_route_mismatch_signal_error_reconnects_if_host_already_down(self): _, local_host = self._discover_local_host_over_unix() self._refresh_control_connection_over_network() local_host.set_down() - connection_error = ConnectionException('control connection failed') + self._use_cluster_down_handling() self.connection.is_defunct = True - self.connection.last_error = connection_error - self.cluster.signal_connection_failure = Mock(return_value=True) + self.connection.last_error = ConnectionException( + 'control connection failed') self.cluster.executor.reset_mock() self.control_connection._signal_error() - self.cluster.signal_connection_failure.assert_called_once_with( - local_host, connection_error, is_host_addition=False) + self.cluster.on_down_potentially_blocking.assert_not_called() self.cluster.executor.submit.assert_called_once_with( self.control_connection._reconnect) def test_route_mismatch_signal_error_reconnects_if_host_reconnecting(self): _, local_host = self._discover_local_host_over_unix() self._refresh_control_connection_over_network() + local_host.set_down() local_host.get_and_set_reconnection_handler(Mock()) - connection_error = ConnectionException('control connection failed') + self._use_cluster_down_handling() self.connection.is_defunct = True - self.connection.last_error = connection_error - - def transition_without_notification(host, *_args, **_kwargs): - host.set_down() - return True - - self.cluster.signal_connection_failure = Mock( - side_effect=transition_without_notification) + self.connection.last_error = ConnectionException( + 'control connection failed') self.cluster.executor.reset_mock() self.control_connection._signal_error() - self.cluster.signal_connection_failure.assert_called_once_with( - local_host, connection_error, is_host_addition=False) + assert local_host.is_up is False + self.cluster.on_down_potentially_blocking.assert_not_called() self.cluster.executor.submit.assert_called_once_with( self.control_connection._reconnect) From 6827a4925f643e55a21c40f85904d07b8f4f5e99 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:23:05 +0000 Subject: [PATCH 188/200] Update actions/checkout action to v7 --- .github/workflows/coverage.yml | 2 +- .github/workflows/docs-pages.yml | 2 +- .github/workflows/docs-pr.yml | 2 +- .github/workflows/integration-tests.yml | 2 +- .github/workflows/lib-build.yml | 6 +++--- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 5d1629f841..324f65eff8 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -46,7 +46,7 @@ jobs: CASS_DRIVER_NO_CYTHON: "1" steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up JDK 8 uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml index 6c4828635b..87b24eef67 100644 --- a/.github/workflows/docs-pages.yml +++ b/.github/workflows/docs-pages.yml @@ -24,7 +24,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.event.repository.default_branch }} persist-credentials: false diff --git a/.github/workflows/docs-pr.yml b/.github/workflows/docs-pr.yml index b829b4820c..004deaffd8 100644 --- a/.github/workflows/docs-pr.yml +++ b/.github/workflows/docs-pr.yml @@ -31,7 +31,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false fetch-depth: 0 diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 565928800f..285fe98477 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -60,7 +60,7 @@ jobs: event_loop_manager: "asyncore" steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up JDK ${{ matrix.java-version }} uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 diff --git a/.github/workflows/lib-build.yml b/.github/workflows/lib-build.yml index 2cda50ad52..1ae8b8bc16 100644 --- a/.github/workflows/lib-build.yml +++ b/.github/workflows/lib-build.yml @@ -77,11 +77,11 @@ jobs: include: ${{ fromJson(needs.prepare-matrix.outputs.matrix) }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Checkout tag ${{ inputs.target_tag }} if: inputs.target_tag != '' - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ inputs.target_tag }} @@ -151,7 +151,7 @@ jobs: name: Build source distribution runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Install uv uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 From 39851ad8334722dab01f32b387efb28e6fe49cc2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:54:21 -0400 Subject: [PATCH 189/200] Update Python to >=3.14,<3.15 (#978) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docs/pyproject.toml | 2 +- docs/uv.lock | 527 ++++++++++++++++++++++++-------------------- 2 files changed, 290 insertions(+), 239 deletions(-) diff --git a/docs/pyproject.toml b/docs/pyproject.toml index 0ca0a61706..23116990e2 100644 --- a/docs/pyproject.toml +++ b/docs/pyproject.toml @@ -4,7 +4,7 @@ version = "0.1.0" description = "ScyllaDB Python Driver Docs" authors = [{ name = "ScyllaDB" }] package-mode = false -requires-python = ">=3.13,<3.14" +requires-python = ">=3.14,<3.15" dependencies = [ "gremlinpython==3.7.4", diff --git a/docs/uv.lock b/docs/uv.lock index ac1216472d..9c56e274fa 100644 --- a/docs/uv.lock +++ b/docs/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = "==3.13.*" +requires-python = "==3.14.*" [[package]] name = "aenum" @@ -35,29 +35,47 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" }, - { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" }, - { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" }, - { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" }, - { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" }, - { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" }, - { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" }, - { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" }, - { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" }, - { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" }, - { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" }, - { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" }, - { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" }, - { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" }, - { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" }, - { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" }, - { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" }, - { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, + { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, + { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, + { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, + { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, + { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, + { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, + { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, + { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, + { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, + { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, + { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, + { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, + { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, + { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, + { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, + { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, + { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, + { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, + { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, ] [[package]] @@ -166,22 +184,38 @@ version = "3.4.7" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, - { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, - { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, - { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, - { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, - { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, - { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, - { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, - { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, - { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, - { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, - { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, - { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, - { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, - { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, - { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] @@ -221,38 +255,38 @@ version = "1.8.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, - { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, - { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, - { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, - { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, - { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, - { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, - { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, - { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, - { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, - { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, - { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, - { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, - { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, - { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, - { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, - { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, - { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, - { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, - { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, - { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, - { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, - { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, - { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, - { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, - { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, - { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, - { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, - { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, - { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] @@ -354,28 +388,28 @@ version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, - { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, - { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, - { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, - { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, - { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, - { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, - { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, - { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, - { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, - { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, - { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, - { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, - { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, - { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, - { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, - { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, - { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, - { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, - { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, - { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] [[package]] @@ -405,42 +439,42 @@ version = "6.7.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, - { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, - { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, - { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, - { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, - { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, - { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, - { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, - { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, - { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, - { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, - { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, - { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, - { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, - { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, - { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, - { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, - { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, - { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, - { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, - { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, - { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, - { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, - { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, - { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, - { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, - { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, - { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, - { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, - { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, - { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, - { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, - { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, - { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] @@ -503,36 +537,36 @@ version = "0.4.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, - { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, - { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, - { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, - { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, - { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, - { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, - { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, - { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, - { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, - { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, - { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, - { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, - { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, - { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, - { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, - { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, - { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, - { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, - { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, - { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, - { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, - { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, - { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, - { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, - { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, - { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, - { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, - { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, + { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, + { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, + { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, + { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, + { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, + { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] @@ -592,16 +626,24 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] [[package]] @@ -1072,29 +1114,29 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, - { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, - { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, - { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, - { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, - { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, - { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, - { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, - { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, - { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, - { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, - { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, - { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, - { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, - { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, - { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, - { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, - { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, - { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, - { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, - { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, ] [[package]] @@ -1103,15 +1145,24 @@ version = "16.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, - { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, - { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, - { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, - { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, - { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] @@ -1126,41 +1177,41 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" }, - { url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" }, - { url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" }, - { url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" }, - { url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" }, - { url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" }, - { url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" }, - { url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" }, - { url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" }, - { url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" }, - { url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" }, - { url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" }, - { url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" }, - { url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" }, - { url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" }, - { url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" }, - { url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" }, - { url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" }, - { url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" }, - { url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" }, - { url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" }, - { url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" }, - { url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" }, - { url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" }, - { url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" }, - { url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" }, - { url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" }, - { url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" }, - { url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" }, - { url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" }, - { url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" }, - { url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" }, - { url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" }, - { url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" }, - { url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" }, + { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" }, + { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" }, + { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" }, + { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" }, + { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" }, + { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" }, + { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" }, + { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" }, + { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" }, + { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" }, + { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" }, + { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" }, + { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" }, + { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" }, + { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" }, + { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" }, + { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" }, + { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" }, + { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" }, + { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" }, + { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, ] From 3141f23eab07595e51b2cbd131d70e23b0fc7ef7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:54:59 -0400 Subject: [PATCH 190/200] Update actions/setup-java action to v5.4.0 (#1039) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/coverage.yml | 2 +- .github/workflows/integration-tests.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 324f65eff8..7e41b75da2 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -49,7 +49,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up JDK 8 - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 with: java-version: 8 distribution: 'adopt' diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 285fe98477..67d39bc105 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -63,7 +63,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up JDK ${{ matrix.java-version }} - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 with: java-version: ${{ matrix.java-version }} distribution: 'adopt' From f0b2f9daf8981758af415f9dd4a6b99b5c9d14ba Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:00:01 -0400 Subject: [PATCH 191/200] Update astral-sh/setup-uv action to v8.2.0 (#1003) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/coverage.yml | 2 +- .github/workflows/docs-pages.yml | 2 +- .github/workflows/docs-pr.yml | 2 +- .github/workflows/integration-tests.yml | 2 +- .github/workflows/lib-build.yml | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 7e41b75da2..b1073476fe 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -58,7 +58,7 @@ jobs: run: sudo apt-get install libev4 libev-dev - name: Install uv - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: python-version: "3.13" diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml index 87b24eef67..1600d1f431 100644 --- a/.github/workflows/docs-pages.yml +++ b/.github/workflows/docs-pages.yml @@ -31,7 +31,7 @@ jobs: fetch-depth: 0 - name: Install uv - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: working-directory: docs enable-cache: true diff --git a/.github/workflows/docs-pr.yml b/.github/workflows/docs-pr.yml index 004deaffd8..d99dcab980 100644 --- a/.github/workflows/docs-pr.yml +++ b/.github/workflows/docs-pr.yml @@ -37,7 +37,7 @@ jobs: fetch-depth: 0 - name: Install uv - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: working-directory: docs enable-cache: true diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 67d39bc105..3908cf7e0d 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -72,7 +72,7 @@ jobs: run: sudo apt-get install libev4 libev-dev - name: Install uv - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/lib-build.yml b/.github/workflows/lib-build.yml index 1ae8b8bc16..34ab762ae5 100644 --- a/.github/workflows/lib-build.yml +++ b/.github/workflows/lib-build.yml @@ -96,7 +96,7 @@ jobs: echo "CIBW_BEFORE_TEST_WINDOWS=(exit 0)" >> $GITHUB_ENV; - name: Install uv - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: python-version: ${{ inputs.python-version }} @@ -154,7 +154,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Install uv - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: python-version: ${{ inputs.python-version }} From cc5819e1674ecb7ed507d18050bf853ea5b3f16d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:00:27 -0400 Subject: [PATCH 192/200] build(deps): bump anyio from 4.13.0 to 4.14.2 in /docs (#1041) Bumps [anyio](https://github.com/agronholm/anyio) from 4.13.0 to 4.14.2. - [Release notes](https://github.com/agronholm/anyio/releases) - [Commits](https://github.com/agronholm/anyio/compare/4.13.0...4.14.2) --- updated-dependencies: - dependency-name: anyio dependency-version: 4.14.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/uv.lock b/docs/uv.lock index 9c56e274fa..e03c2e51dd 100644 --- a/docs/uv.lock +++ b/docs/uv.lock @@ -110,14 +110,14 @@ wheels = [ [[package]] name = "anyio" -version = "4.13.0" +version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] [[package]] From fe0f0c9488bace22c798df394f0e7042d1318ab6 Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Thu, 24 Sep 2026 15:17:22 -0400 Subject: [PATCH 193/200] build(deps): remove vulnerable Python 3.9 resolutions (#1043) * build(deps): remove vulnerable Python 3.9 resolutions Align package metadata with the existing Python 3.9 support removal so uv no longer retains unsupported compatibility branches containing vulnerable dependency versions. Require the patched pytest 9.1 line using a compatible-release specifier. This avoids a literal "<10", which cibuildwheel passes through cmd.exe as input redirection when installing test dependencies on Windows. * tests: fix timestamp comparison under pytest 9 The vector round-trip helper calls comparison callbacks with observed and expected values. Passing both to a partial pytest.approx call made expected the relative-tolerance argument and discarded the returned matcher, so the timestamp path performed no assertion under pytest 8. Pytest 9 rejects relative tolerance for datetimes and exposes that misuse. Compare observed with the matcher explicitly and disable relative tolerance so timestamps retain their intended one-second absolute tolerance. --- pyproject.toml | 6 +- tests/integration/standard/test_types.py | 7 +- uv.lock | 1505 +--------------------- 3 files changed, 71 insertions(+), 1447 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 845a4a74ee..26c720c765 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ dependencies = ['geomet>=1.1', 'pyyaml > 5.0'] dynamic = ["version", "readme"] license = {text = "Apache-2.0"} -requires-python = ">=3.9" +requires-python = ">=3.10" [project.urls] "Homepage" = "https://github.com/scylladb/python-driver" @@ -43,7 +43,9 @@ auth-kerberos = [ [dependency-groups] dev = [ - "pytest~=8.0", + # Keep the patched 9.x range without a literal "<": cibuildwheel runs + # test-group installs through cmd.exe, where "<" is input redirection. + "pytest~=9.1", "PyYAML", "pure-sasl", "cryptography>=42.0", diff --git a/tests/integration/standard/test_types.py b/tests/integration/standard/test_types.py index cc946bf0d5..c550915bda 100644 --- a/tests/integration/standard/test_types.py +++ b/tests/integration/standard/test_types.py @@ -1069,7 +1069,10 @@ def _random_string(): self._round_trip_test("text", _random_string, assertEqual) def test_round_trip_date_and_time(self): - _almost_equal_test_fn = partial(pytest.approx, abs=timedelta(seconds=1)) + def _assert_almost_equal(observed, expected): + # Datetimes support an absolute timedelta, not relative tolerance. + assert observed == pytest.approx(expected, abs=timedelta(seconds=1), rel=None) + def _random_datetime(): return datetime.today() - timedelta(hours=random.randint(0,18), days=random.randint(1,1000)) def _random_date(): @@ -1079,7 +1082,7 @@ def _random_time(): self._round_trip_test("date", _random_date, assertEqual) self._round_trip_test("time", _random_time, assertEqual) - self._round_trip_test("timestamp", _random_datetime, _almost_equal_test_fn) + self._round_trip_test("timestamp", _random_datetime, _assert_almost_equal) def test_round_trip_uuid(self): self._round_trip_test("uuid", uuid.uuid1, assertEqual) diff --git a/uv.lock b/uv.lock index d8822e0ad5..49bf3a4db6 100644 --- a/uv.lock +++ b/uv.lock @@ -1,13 +1,10 @@ version = 1 revision = 3 -requires-python = ">=3.9" +requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.12'", "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", - "python_full_version >= '3.9.12' and python_full_version < '3.10'", - "python_full_version > '3.9' and python_full_version < '3.9.12'", - "python_full_version <= '3.9'", + "python_full_version < '3.11'", ] [[package]] @@ -19,195 +16,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/8d/1fe30c6fd8999b9d462547c4a1bb6690bda24af38f2913c4bec7decb81f2/aenum-3.1.17-py3-none-any.whl", hash = "sha256:8b883a37a04e74cc838ac442bdd28c266eae5bbf13e1342c7ef123ed25230139", size = 165560, upload-time = "2026-03-20T20:43:27.681Z" }, ] -[[package]] -name = "aiohappyeyeballs" -version = "2.6.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.9.12' and python_full_version < '3.10'", - "python_full_version > '3.9' and python_full_version < '3.9.12'", - "python_full_version <= '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, -] - [[package]] name = "aiohappyeyeballs" version = "2.7.1" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, ] -[[package]] -name = "aiohttp" -version = "3.13.5" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.9.12' and python_full_version < '3.10'", - "python_full_version > '3.9' and python_full_version < '3.9.12'", - "python_full_version <= '3.9'", -] -dependencies = [ - { name = "aiohappyeyeballs", version = "2.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "aiosignal", marker = "python_full_version < '3.10'" }, - { name = "async-timeout", marker = "python_full_version < '3.10'" }, - { name = "attrs", marker = "python_full_version < '3.10'" }, - { name = "frozenlist", marker = "python_full_version < '3.10'" }, - { name = "multidict", marker = "python_full_version < '3.10'" }, - { name = "propcache", version = "0.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "yarl", version = "1.22.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/85/cebc47ee74d8b408749073a1a46c6fcba13d170dc8af7e61996c6c9394ac/aiohttp-3.13.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:02222e7e233295f40e011c1b00e3b0bd451f22cf853a0304c3595633ee47da4b", size = 750547, upload-time = "2026-03-31T21:56:30.024Z" }, - { url = "https://files.pythonhosted.org/packages/05/98/afd308e35b9d3d8c9ec54c0918f1d722c86dc17ddfec272fcdbcce5a3124/aiohttp-3.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bace460460ed20614fa6bc8cb09966c0b8517b8c58ad8046828c6078d25333b5", size = 503535, upload-time = "2026-03-31T21:56:31.935Z" }, - { url = "https://files.pythonhosted.org/packages/6f/4d/926c183e06b09d5270a309eb50fbde7b09782bfd305dec1e800f329834fb/aiohttp-3.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f546a4dc1e6a5edbb9fd1fd6ad18134550e096a5a43f4ad74acfbd834fc6670", size = 497830, upload-time = "2026-03-31T21:56:33.654Z" }, - { url = "https://files.pythonhosted.org/packages/e4/d6/f47d1c690f115a5c2a5e8938cce4a232a5be9aac5c5fb2647efcbbbda333/aiohttp-3.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c86969d012e51b8e415a8c6ce96f7857d6a87d6207303ab02d5d11ef0cad2274", size = 1682474, upload-time = "2026-03-31T21:56:35.513Z" }, - { url = "https://files.pythonhosted.org/packages/01/44/056fd37b1bb52eac760303e5196acc74d9d546631b035704ae5927f7b4ac/aiohttp-3.13.5-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b6f6cd1560c5fa427e3b6074bb24d2c64e225afbb7165008903bd42e4e33e28a", size = 1655259, upload-time = "2026-03-31T21:56:37.843Z" }, - { url = "https://files.pythonhosted.org/packages/91/9f/78eb1a20c1c28ae02f6a3c0f4d7b0dcc66abce5290cadd53d78ce3084175/aiohttp-3.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:636bc362f0c5bbc7372bc3ae49737f9e3030dbce469f0f422c8f38079780363d", size = 1736204, upload-time = "2026-03-31T21:56:39.822Z" }, - { url = "https://files.pythonhosted.org/packages/de/6c/d20d7de23f0b52b8c1d9e2033b2db1ac4dacbb470bb74c56de0f5f86bb4f/aiohttp-3.13.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6a7cbeb06d1070f1d14895eeeed4dac5913b22d7b456f2eb969f11f4b3993796", size = 1826198, upload-time = "2026-03-31T21:56:41.378Z" }, - { url = "https://files.pythonhosted.org/packages/2f/86/a6f3ff1fd795f49545a7c74b2c92f62729135d73e7e4055bf74da5a26c82/aiohttp-3.13.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca9ef7517fd7874a1a08970ae88f497bf5c984610caa0bf40bd7e8450852b95", size = 1681329, upload-time = "2026-03-31T21:56:43.374Z" }, - { url = "https://files.pythonhosted.org/packages/fb/68/84cd3dab6b7b4f3e6fe9459a961acb142aaab846417f6e8905110d7027e5/aiohttp-3.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:019a67772e034a0e6b9b17c13d0a8fe56ad9fb150fc724b7f3ffd3724288d9e5", size = 1560023, upload-time = "2026-03-31T21:56:45.031Z" }, - { url = "https://files.pythonhosted.org/packages/41/2c/db61b64b0249e30f954a65ab4cb4970ced57544b1de2e3c98ee5dc24165f/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f34ecee82858e41dd217734f0c41a532bd066bcaab636ad830f03a30b2a96f2a", size = 1652372, upload-time = "2026-03-31T21:56:47.075Z" }, - { url = "https://files.pythonhosted.org/packages/25/6f/e96988a6c982d047810c772e28c43c64c300c943b0ed5c1c0c4ce1e1027c/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4eac02d9af4813ee289cd63a361576da36dba57f5a1ab36377bc2600db0cbb73", size = 1662031, upload-time = "2026-03-31T21:56:48.835Z" }, - { url = "https://files.pythonhosted.org/packages/b7/26/a56feace81f3d347b4052403a9d03754a0ab23f7940780dada0849a38c92/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4beac52e9fe46d6abf98b0176a88154b742e878fdf209d2248e99fcdf73cd297", size = 1708118, upload-time = "2026-03-31T21:56:50.833Z" }, - { url = "https://files.pythonhosted.org/packages/78/6e/b6173a8ff03d01d5e1a694bc06764b5dad1df2d4ed8f0ceec12bb3277936/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c180f480207a9b2475f2b8d8bd7204e47aec952d084b2a2be58a782ffcf96074", size = 1548667, upload-time = "2026-03-31T21:56:52.81Z" }, - { url = "https://files.pythonhosted.org/packages/16/13/13296ffe2c132d888b3fe2c195c8b9c0c24c89c3fa5cc2c44464dc23b22e/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2837fb92951564d6339cedae4a7231692aa9f73cbc4fb2e04263b96844e03b4e", size = 1724490, upload-time = "2026-03-31T21:56:54.541Z" }, - { url = "https://files.pythonhosted.org/packages/7a/b4/1f1c287f4a79782ef36e5a6e62954c85343bc30470d862d30bd5f26c9fa2/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d9010032a0b9710f58012a1e9c222528763d860ba2ee1422c03473eab47703e7", size = 1667109, upload-time = "2026-03-31T21:56:56.21Z" }, - { url = "https://files.pythonhosted.org/packages/ef/42/8461a2aaf60a8f4ea4549a4056be36b904b0eb03d97ca9a8a2604681a500/aiohttp-3.13.5-cp310-cp310-win32.whl", hash = "sha256:7c4b6668b2b2b9027f209ddf647f2a4407784b5d88b8be4efcc72036f365baf9", size = 439478, upload-time = "2026-03-31T21:56:58.292Z" }, - { url = "https://files.pythonhosted.org/packages/e5/71/06956304cb5ee439dfe8d86e1b2e70088bd88ed1ced1f42fb29e5d855f0e/aiohttp-3.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:cd3db5927bf9167d5a6157ddb2f036f6b6b0ad001ac82355d43e97a4bde76d76", size = 462047, upload-time = "2026-03-31T21:57:00.257Z" }, - { url = "https://files.pythonhosted.org/packages/d6/f5/a20c4ac64aeaef1679e25c9983573618ff765d7aa829fa2b84ae7573169e/aiohttp-3.13.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ab7229b6f9b5c1ba4910d6c41a9eb11f543eadb3f384df1b4c293f4e73d44d6", size = 757513, upload-time = "2026-03-31T21:57:02.146Z" }, - { url = "https://files.pythonhosted.org/packages/75/0a/39fa6c6b179b53fcb3e4b3d2b6d6cad0180854eda17060c7218540102bef/aiohttp-3.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8f14c50708bb156b3a3ca7230b3d820199d56a48e3af76fa21c2d6087190fe3d", size = 506748, upload-time = "2026-03-31T21:57:04.275Z" }, - { url = "https://files.pythonhosted.org/packages/87/ec/e38ce072e724fd7add6243613f8d1810da084f54175353d25ccf9f9c7e5a/aiohttp-3.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7d2f8616f0ff60bd332022279011776c3ac0faa0f1b463f7bb12326fbc97a1c", size = 501673, upload-time = "2026-03-31T21:57:06.208Z" }, - { url = "https://files.pythonhosted.org/packages/ba/ba/3bc7525d7e2beaa11b309a70d48b0d3cfc3c2089ec6a7d0820d59c657053/aiohttp-3.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2567b72e1ffc3ab25510db43f355b29eeada56c0a622e58dcdb19530eb0a3cb", size = 1763757, upload-time = "2026-03-31T21:57:07.882Z" }, - { url = "https://files.pythonhosted.org/packages/5e/ab/e87744cf18f1bd78263aba24924d4953b41086bd3a31d22452378e9028a0/aiohttp-3.13.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fb0540c854ac9c0c5ad495908fdfd3e332d553ec731698c0e29b1877ba0d2ec6", size = 1720152, upload-time = "2026-03-31T21:57:09.946Z" }, - { url = "https://files.pythonhosted.org/packages/6b/f3/ed17a6f2d742af17b50bae2d152315ed1b164b07a5fd5cc1754d99e4dfa5/aiohttp-3.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9883051c6972f58bfc4ebb2116345ee2aa151178e99c3f2b2bbe2af712abd13", size = 1818010, upload-time = "2026-03-31T21:57:12.157Z" }, - { url = "https://files.pythonhosted.org/packages/53/06/ecbc63dc937192e2a5cb46df4d3edb21deb8225535818802f210a6ea5816/aiohttp-3.13.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2294172ce08a82fb7c7273485895de1fa1186cc8294cfeb6aef4af42ad261174", size = 1907251, upload-time = "2026-03-31T21:57:14.023Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a5/0521aa32c1ddf3aa1e71dcc466be0b7db2771907a13f18cddaa45967d97b/aiohttp-3.13.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a807cabd5115fb55af198b98178997a5e0e57dead43eb74a93d9c07d6d4a7dc", size = 1759969, upload-time = "2026-03-31T21:57:16.146Z" }, - { url = "https://files.pythonhosted.org/packages/f6/78/a38f8c9105199dd3b9706745865a8a59d0041b6be0ca0cc4b2ccf1bab374/aiohttp-3.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6d0d932e0f39c02b80744273cd5c388a2d9bc07760a03164f229c8e02662f6", size = 1616871, upload-time = "2026-03-31T21:57:17.856Z" }, - { url = "https://files.pythonhosted.org/packages/6f/41/27392a61ead8ab38072105c71aa44ff891e71653fe53d576a7067da2b4e8/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60869c7ac4aaabe7110f26499f3e6e5696eae98144735b12a9c3d9eae2b51a49", size = 1739844, upload-time = "2026-03-31T21:57:19.679Z" }, - { url = "https://files.pythonhosted.org/packages/6e/55/5564e7ae26d94f3214250009a0b1c65a0c6af4bf88924ccb6fdab901de28/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:26d2f8546f1dfa75efa50c3488215a903c0168d253b75fba4210f57ab77a0fb8", size = 1731969, upload-time = "2026-03-31T21:57:22.006Z" }, - { url = "https://files.pythonhosted.org/packages/6d/c5/705a3929149865fc941bcbdd1047b238e4a72bcb215a9b16b9d7a2e8d992/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1162a1492032c82f14271e831c8f4b49f2b6078f4f5fc74de2c912fa225d51d", size = 1795193, upload-time = "2026-03-31T21:57:24.256Z" }, - { url = "https://files.pythonhosted.org/packages/a6/19/edabed62f718d02cff7231ca0db4ef1c72504235bc467f7b67adb1679f48/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8b14eb3262fad0dc2f89c1a43b13727e709504972186ff6a99a3ecaa77102b6c", size = 1606477, upload-time = "2026-03-31T21:57:26.364Z" }, - { url = "https://files.pythonhosted.org/packages/de/fc/76f80ef008675637d88d0b21584596dc27410a990b0918cb1e5776545b5b/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ca9ac61ac6db4eb6c2a0cd1d0f7e1357647b638ccc92f7e9d8d133e71ed3c6ac", size = 1813198, upload-time = "2026-03-31T21:57:28.316Z" }, - { url = "https://files.pythonhosted.org/packages/e5/67/5b3ac26b80adb20ea541c487f73730dc8fa107d632c998f25bbbab98fcda/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7996023b2ed59489ae4762256c8516df9820f751cf2c5da8ed2fb20ee50abab3", size = 1752321, upload-time = "2026-03-31T21:57:30.549Z" }, - { url = "https://files.pythonhosted.org/packages/88/06/e4a2e49255ea23fa4feeb5ab092d90240d927c15e47b5b5c48dff5a9ce29/aiohttp-3.13.5-cp311-cp311-win32.whl", hash = "sha256:77dfa48c9f8013271011e51c00f8ada19851f013cde2c48fca1ba5e0caf5bb06", size = 439069, upload-time = "2026-03-31T21:57:32.388Z" }, - { url = "https://files.pythonhosted.org/packages/c0/43/8c7163a596dab4f8be12c190cf467a1e07e4734cf90eebb39f7f5d53fc6a/aiohttp-3.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:d3a4834f221061624b8887090637db9ad4f61752001eae37d56c52fddade2dc8", size = 462859, upload-time = "2026-03-31T21:57:34.455Z" }, - { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" }, - { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" }, - { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" }, - { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" }, - { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" }, - { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" }, - { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" }, - { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" }, - { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" }, - { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" }, - { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" }, - { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" }, - { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" }, - { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" }, - { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" }, - { url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" }, - { url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" }, - { url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" }, - { url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" }, - { url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" }, - { url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" }, - { url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" }, - { url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" }, - { url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" }, - { url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" }, - { url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" }, - { url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" }, - { url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" }, - { url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" }, - { url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" }, - { url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" }, - { url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" }, - { url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" }, - { url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" }, - { url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" }, - { url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" }, - { url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" }, - { url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" }, - { url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" }, - { url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" }, - { url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" }, - { url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" }, - { url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" }, - { url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" }, - { url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" }, - { url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" }, - { url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" }, - { url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" }, - { url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" }, - { url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" }, - { url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" }, - { url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" }, - { url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" }, - { url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" }, - { url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" }, - { url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" }, - { url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" }, - { url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" }, - { url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" }, - { url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" }, - { url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" }, - { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" }, - { url = "https://files.pythonhosted.org/packages/e2/a5/630bc484695d4a1342bbae85fb8689bf979106525684fc88f05b397324ad/aiohttp-3.13.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:347542f0ea3f95b2a955ee6656461fa1c776e401ac50ebce055a6c38454a0adf", size = 752872, upload-time = "2026-03-31T22:00:15.553Z" }, - { url = "https://files.pythonhosted.org/packages/cd/b8/6a19dda37fda94a9ebefb3c1ae0ff419ac7fbf4fb40750e992829fc13614/aiohttp-3.13.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:178c7b5e62b454c2bc790786e6058c3cc968613b4419251b478c153a4aec32b1", size = 504582, upload-time = "2026-03-31T22:00:18.191Z" }, - { url = "https://files.pythonhosted.org/packages/d5/34/8413eafee3421ade2d6ce9e7c0da1213e1d7f0049be09dcdc342b03a39ba/aiohttp-3.13.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:af545c2cffdb0967a96b6249e6f5f7b0d92cdfd267f9d5238d5b9ca63e8edb10", size = 499094, upload-time = "2026-03-31T22:00:21.118Z" }, - { url = "https://files.pythonhosted.org/packages/da/cf/c6f97006093d1e8ca40fbab843ff49ec7725ab668f0714dd1cb702c62cbd/aiohttp-3.13.5-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:206b7b3ef96e4ce211754f0cd003feb28b7d81f0ad26b8d077a5d5161436067f", size = 1669505, upload-time = "2026-03-31T22:00:24.01Z" }, - { url = "https://files.pythonhosted.org/packages/c2/27/3b2288e66dcec8b04771b2bee3909f70e4072bea995cde5ab7e775e73ddc/aiohttp-3.13.5-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ee5e86776273de1795947d17bddd6bb19e0365fd2af4289c0d2c5454b6b1d36b", size = 1648928, upload-time = "2026-03-31T22:00:27.001Z" }, - { url = "https://files.pythonhosted.org/packages/3a/7f/605d766887594a88dcc27a19663499c7c5e13e7aa87f129b763765a2ee63/aiohttp-3.13.5-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:95d14ca7abefde230f7639ec136ade282655431fd5db03c343b19dda72dd1643", size = 1731800, upload-time = "2026-03-31T22:00:29.603Z" }, - { url = "https://files.pythonhosted.org/packages/71/94/5a878e728e30699d22b118f1a6ad576ab6fff9eb2c6fc8a7faa9376a1c3e/aiohttp-3.13.5-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:912d4b6af530ddb1338a66229dac3a25ff11d4448be3ec3d6340583995f56031", size = 1824247, upload-time = "2026-03-31T22:00:32.139Z" }, - { url = "https://files.pythonhosted.org/packages/37/99/84b448291e9996bb83bf4fad3a71a9786d542f19c50a3ff0531bfaba6fac/aiohttp-3.13.5-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e999f0c88a458c836d5fb521814e92ed2172c649200336a6df514987c1488258", size = 1670742, upload-time = "2026-03-31T22:00:34.788Z" }, - { url = "https://files.pythonhosted.org/packages/14/a8/d8d5d1ab6d29a4a3bdb9db31f161e338bfdf6638f6574ea8380f1d4a243c/aiohttp-3.13.5-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39380e12bd1f2fdab4285b6e055ad48efbaed5c836433b142ed4f5b9be71036a", size = 1562474, upload-time = "2026-03-31T22:00:37.623Z" }, - { url = "https://files.pythonhosted.org/packages/92/e8/bd889697916f10b65524422c61b4eeaf919eb35a170290cccb680cbe4eb4/aiohttp-3.13.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9efcc0f11d850cefcafdd9275b9576ad3bfb539bed96807663b32ad99c4d4b88", size = 1642235, upload-time = "2026-03-31T22:00:40.541Z" }, - { url = "https://files.pythonhosted.org/packages/60/42/3f1928107131f1413a5972ace14ddcd5364968e9bd7b3ad71272defafc9c/aiohttp-3.13.5-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:147b4f501d0292077f29d5268c16bb7c864a1f054d7001c4c1812c0421ea1ed0", size = 1655397, upload-time = "2026-03-31T22:00:43.167Z" }, - { url = "https://files.pythonhosted.org/packages/b2/79/c4bbcf4cac3a4715a326e49720ccdc3a4b5e14a367c5029eae7727d06029/aiohttp-3.13.5-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:d147004fede1b12f6013a6dbb2a26a986a671a03c6ea740ddc76500e5f1c399f", size = 1703509, upload-time = "2026-03-31T22:00:45.908Z" }, - { url = "https://files.pythonhosted.org/packages/d1/e6/32d245876f211a7308a7d5437707f9296b1f9837a2888a407ed04e61321c/aiohttp-3.13.5-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:9277145d36a01653863899c665243871434694bcc3431922c3b35c978061bdb8", size = 1550098, upload-time = "2026-03-31T22:00:49.48Z" }, - { url = "https://files.pythonhosted.org/packages/db/62/ab0f1304def56ce2356e6fbb9f0b024d6544010351430070f48f53b89e0a/aiohttp-3.13.5-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4e704c52438f66fdd89588346183d898bb42167cf88f8b7ff1c0f9fc957c348f", size = 1724326, upload-time = "2026-03-31T22:00:52.165Z" }, - { url = "https://files.pythonhosted.org/packages/c4/9a/aab4469689024046220ea438aa020ea2ae04cd1dd71aea3057e094f8c357/aiohttp-3.13.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a8a4d3427e8de1312ddf309cc482186466c79895b3a139fed3259fc01dfa9a5b", size = 1658824, upload-time = "2026-03-31T22:00:55.122Z" }, - { url = "https://files.pythonhosted.org/packages/b0/98/bcc35d4db687acabf06d41f561a99fa88bca145292513388c858d99b72c5/aiohttp-3.13.5-cp39-cp39-win32.whl", hash = "sha256:6f497a6876aa4b1a102b04996ce4c1170c7040d83faa9387dd921c16e30d5c83", size = 440302, upload-time = "2026-03-31T22:00:57.673Z" }, - { url = "https://files.pythonhosted.org/packages/25/61/b0203c2ef6bd268fca0eda142f0efbba7cbebd7ad38f7bb01dd31c2ff68e/aiohttp-3.13.5-cp39-cp39-win_amd64.whl", hash = "sha256:cb979826071c0986a5f08333a36104153478ce6018c58cba7f9caddaf63d5d67", size = 463076, upload-time = "2026-03-31T22:01:00.264Z" }, -] - [[package]] name = "aiohttp" version = "3.14.3" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] dependencies = [ - { name = "aiohappyeyeballs", version = "2.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "aiosignal", marker = "python_full_version >= '3.10'" }, - { name = "async-timeout", marker = "python_full_version == '3.10.*'" }, - { name = "attrs", marker = "python_full_version >= '3.10'" }, - { name = "frozenlist", marker = "python_full_version >= '3.10'" }, - { name = "multidict", marker = "python_full_version >= '3.10'" }, - { name = "propcache", version = "0.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, - { name = "yarl", version = "1.24.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "async-timeout", marker = "python_full_version < '3.11'" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "yarl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } wheels = [ @@ -362,76 +193,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] -[[package]] -name = "boto3" -version = "1.42.97" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.9.12' and python_full_version < '3.10'", - "python_full_version > '3.9' and python_full_version < '3.9.12'", - "python_full_version <= '3.9'", -] -dependencies = [ - { name = "botocore", version = "1.42.97", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "jmespath", marker = "python_full_version < '3.10'" }, - { name = "s3transfer", version = "0.16.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/55/7d/5c6fa0bb9fd5caf865b9356411793900304328bcd0bc1eda96a32a1368a6/boto3-1.42.97.tar.gz", hash = "sha256:2833dbeda3670ea610ad48dff7d27cdc829dbbfcdfbc6b750b673948e949b6f0", size = 113217, upload-time = "2026-04-27T20:39:17.646Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/43/84c1888139aa1aaf1dc53f8f914e6ec629e5a571fbafdd42fb2d98ac361f/boto3-1.42.97-py3-none-any.whl", hash = "sha256:966e49f0510af9a64057a902b7df53d4348c447de0d3df4cc855dfd85e058fcd", size = 140556, upload-time = "2026-04-27T20:39:15.509Z" }, -] - [[package]] name = "boto3" version = "1.43.64" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] dependencies = [ - { name = "botocore", version = "1.43.64", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "jmespath", marker = "python_full_version >= '3.10'" }, - { name = "s3transfer", version = "0.19.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, ] sdist = { url = "https://files.pythonhosted.org/packages/51/da/d5ca15f34a567f2d027df23395315983bd7ee35011e93c1cca12b7f89823/boto3-1.43.64.tar.gz", hash = "sha256:fc7522c3ed97d38176e0b1366406bca0fc8c888ae8d416bf953477b3f015a7b2", size = 112655, upload-time = "2026-08-04T20:12:42.45Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/15/94/14cf3ec553edf78e5196289087ac52862c87ac179fa1cfdf1022ec366b79/boto3-1.43.64-py3-none-any.whl", hash = "sha256:2b555e63ece57cffb1ab1666fa6c23a0957e61f9d19ea8424d6c884124c3fd98", size = 140024, upload-time = "2026-08-04T20:12:41.147Z" }, ] -[[package]] -name = "botocore" -version = "1.42.97" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.9.12' and python_full_version < '3.10'", - "python_full_version > '3.9' and python_full_version < '3.9.12'", - "python_full_version <= '3.9'", -] -dependencies = [ - { name = "jmespath", marker = "python_full_version < '3.10'" }, - { name = "python-dateutil", marker = "python_full_version < '3.10'" }, - { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c6/95/c37edb602948fad2253ffd1bb3dba5b938645bd1845ee4160350136a0f41/botocore-1.42.97.tar.gz", hash = "sha256:5c0bb00e32d16ff6d278cc8c9e10dc3672d9c1d569031635ac3c908a60de8310", size = 15269348, upload-time = "2026-04-27T20:39:05.625Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/d2/8e025ba1a4e257879af72d06913272311af79673d82fa2581a351b924317/botocore-1.42.97-py3-none-any.whl", hash = "sha256:77d2c8ce1bc592d3fbd7c01c35836f4a5b0cac2ca03ccdf6ffc60faa16b5fadc", size = 14950367, upload-time = "2026-04-27T20:39:01.261Z" }, -] - [[package]] name = "botocore" version = "1.43.64" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] dependencies = [ - { name = "jmespath", marker = "python_full_version >= '3.10'" }, - { name = "python-dateutil", marker = "python_full_version >= '3.10'" }, - { name = "urllib3", version = "2.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3b/67/0e39203c5c67f750874478cf89124486044580ec5cb391eec8eb13cf25b8/botocore-1.43.64.tar.gz", hash = "sha256:a2bb131f48111094fae0a2c896b42593797e935d9c22abb2ebae4290c6dbb5ea", size = 15842274, upload-time = "2026-08-04T20:12:31.598Z" } wheels = [ @@ -443,16 +226,13 @@ name = "ccm" version = "2.0.5" source = { git = "https://github.com/scylladb/scylla-ccm.git?rev=master#ad068edc3524309b54428c1babb31ab7e7af2c89" } dependencies = [ - { name = "boto3", version = "1.42.97", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "boto3", version = "1.43.64", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "boto3" }, { name = "packaging" }, { name = "psutil" }, - { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "requests" }, { name = "ruamel-yaml" }, { name = "tqdm" }, - { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "urllib3", version = "2.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "urllib3" }, ] [[package]] @@ -464,116 +244,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] -[[package]] -name = "cffi" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.9.12' and python_full_version < '3.10'", - "python_full_version > '3.9' and python_full_version < '3.9.12'", - "python_full_version <= '3.9'", -] -dependencies = [ - { name = "pycparser", version = "2.23", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and implementation_name != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, - { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, - { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, - { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, - { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, - { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, - { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, - { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, - { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, - { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, - { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, - { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, - { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, - { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, - { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, - { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, - { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, - { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, - { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, - { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, - { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, - { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, - { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, - { url = "https://files.pythonhosted.org/packages/c0/cc/08ed5a43f2996a16b462f64a7055c6e962803534924b9b2f1371d8c00b7b/cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf", size = 184288, upload-time = "2025-09-08T23:23:48.404Z" }, - { url = "https://files.pythonhosted.org/packages/3d/de/38d9726324e127f727b4ecc376bc85e505bfe61ef130eaf3f290c6847dd4/cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7", size = 180509, upload-time = "2025-09-08T23:23:49.73Z" }, - { url = "https://files.pythonhosted.org/packages/9b/13/c92e36358fbcc39cf0962e83223c9522154ee8630e1df7c0b3a39a8124e2/cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c", size = 208813, upload-time = "2025-09-08T23:23:51.263Z" }, - { url = "https://files.pythonhosted.org/packages/15/12/a7a79bd0df4c3bff744b2d7e52cc1b68d5e7e427b384252c42366dc1ecbc/cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165", size = 216498, upload-time = "2025-09-08T23:23:52.494Z" }, - { url = "https://files.pythonhosted.org/packages/a3/ad/5c51c1c7600bdd7ed9a24a203ec255dccdd0ebf4527f7b922a0bde2fb6ed/cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534", size = 203243, upload-time = "2025-09-08T23:23:53.836Z" }, - { url = "https://files.pythonhosted.org/packages/32/f2/81b63e288295928739d715d00952c8c6034cb6c6a516b17d37e0c8be5600/cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f", size = 203158, upload-time = "2025-09-08T23:23:55.169Z" }, - { url = "https://files.pythonhosted.org/packages/1f/74/cc4096ce66f5939042ae094e2e96f53426a979864aa1f96a621ad128be27/cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63", size = 216548, upload-time = "2025-09-08T23:23:56.506Z" }, - { url = "https://files.pythonhosted.org/packages/e8/be/f6424d1dc46b1091ffcc8964fa7c0ab0cd36839dd2761b49c90481a6ba1b/cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2", size = 218897, upload-time = "2025-09-08T23:23:57.825Z" }, - { url = "https://files.pythonhosted.org/packages/f7/e0/dda537c2309817edf60109e39265f24f24aa7f050767e22c98c53fe7f48b/cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65", size = 211249, upload-time = "2025-09-08T23:23:59.139Z" }, - { url = "https://files.pythonhosted.org/packages/2b/e7/7c769804eb75e4c4b35e658dba01de1640a351a9653c3d49ca89d16ccc91/cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322", size = 218041, upload-time = "2025-09-08T23:24:00.496Z" }, - { url = "https://files.pythonhosted.org/packages/aa/d9/6218d78f920dcd7507fc16a766b5ef8f3b913cc7aa938e7fc80b9978d089/cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a", size = 172138, upload-time = "2025-09-08T23:24:01.7Z" }, - { url = "https://files.pythonhosted.org/packages/54/8f/a1e836f82d8e32a97e6b29cc8f641779181ac7363734f12df27db803ebda/cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9", size = 182794, upload-time = "2025-09-08T23:24:02.943Z" }, -] - [[package]] name = "cffi" version = "2.1.1" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] dependencies = [ - { name = "pycparser", version = "3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and implementation_name != 'PyPy'" }, + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } wheels = [ @@ -762,50 +438,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, - { url = "https://files.pythonhosted.org/packages/a6/ec/81e22253f4b7091eca6515bb3da5e45d05a663f7f567bb745695dc60f892/charset_normalizer-3.4.9-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:253a4a220747e8b5faf57ec320c4f5efb0cef05f647420bf267143ec15dba10a", size = 306122, upload-time = "2026-07-07T14:34:36.607Z" }, - { url = "https://files.pythonhosted.org/packages/c8/53/a8c042eb9eee4716f4d42a0f5a571eb32a09ec429be9fb0b8b9d765393ba/charset_normalizer-3.4.9-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68ce9f4d6b26d5ccbf7fd4459bf75f74a0a146677ebba80597df60cbdb20e6f4", size = 206284, upload-time = "2026-07-07T14:34:38.166Z" }, - { url = "https://files.pythonhosted.org/packages/14/cb/1db8b96547ee3186cd2dd7f2e59dd560a9b80748f3604171f3c153d62811/charset_normalizer-3.4.9-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:58150c9f9b9a552505912d182ccdf26f6396fb6094816ceebcbb20eecabaed94", size = 226837, upload-time = "2026-07-07T14:34:39.77Z" }, - { url = "https://files.pythonhosted.org/packages/6a/05/c94d5cd23396289c54c93b02e0273b4dd8921641d9968c4828caf9bbaad9/charset_normalizer-3.4.9-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df7276909358e5635ae203673ab7e509ddd224225a8d6b0790bf13eb2bde1cc5", size = 222199, upload-time = "2026-07-07T14:34:41.391Z" }, - { url = "https://files.pythonhosted.org/packages/6d/46/79847edd07244a4a2d443c6655a7b6ee94203c21539414b059f32713c357/charset_normalizer-3.4.9-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c09a49d6cde137258beb3d551994a2927fd35ad5cf96aed573f61bbd67c5f84", size = 214344, upload-time = "2026-07-07T14:34:42.986Z" }, - { url = "https://files.pythonhosted.org/packages/ec/b4/ef5a49b2e77c00deb43bb3256592b115ba9e4346016e82c516b8d215bf68/charset_normalizer-3.4.9-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:231ddcbb35e2ff8973e1365db41fe0572662893b99a05deb183b68ad4c0c8bd4", size = 199988, upload-time = "2026-07-07T14:34:44.685Z" }, - { url = "https://files.pythonhosted.org/packages/3d/ca/ad1d7c7d3077dab873f539d3e1d083c0845a762cb0bafdfbe3ef93add598/charset_normalizer-3.4.9-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:920079c3f7456fa213e0829ed2073aaa727fd39d889ead5b4f35d0de5460d04f", size = 211908, upload-time = "2026-07-07T14:34:46.227Z" }, - { url = "https://files.pythonhosted.org/packages/ed/61/710738687f90d01c06a04ed52d6ca1e62dd9b1d8cc2567098167c4691034/charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0fa1aec2d32bcc03c8fa0f6f1712caad1adc38509f31142112e5c9daf5b9c833", size = 209320, upload-time = "2026-07-07T14:34:47.753Z" }, - { url = "https://files.pythonhosted.org/packages/5f/c0/6eec7bdabe6cbbcc274ec04596f6d93865751a0541d33d60d1ce179bd372/charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ad41ba96094304aa090f5a30cb6e4fb3b3f1c264c523394b4c39bbacc4dc92ba", size = 200980, upload-time = "2026-07-07T14:34:49.362Z" }, - { url = "https://files.pythonhosted.org/packages/eb/78/59344ff9a4a7b5f6530bf7bec2c980047cc42c3a616596cdbd8cb5c1a1af/charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:43b9e366a31fdd1c87d0eb08f579b4a82b723ea54338f040d6b4e518a026ea29", size = 216545, upload-time = "2026-07-07T14:34:50.98Z" }, - { url = "https://files.pythonhosted.org/packages/17/6d/bff78a4bacc4891bc63ec5bdc6776d8c85e47fab93d0d5f6223068fad0a4/charset_normalizer-3.4.9-cp39-cp39-win32.whl", hash = "sha256:93d59d504b230e83c7a843251681959a0b6a9cd76f6e146ce1b8a80eb8739af9", size = 146256, upload-time = "2026-07-07T14:34:52.509Z" }, - { url = "https://files.pythonhosted.org/packages/a2/55/86048bde1c9d0352940bd7b87d825091a52aef67d01cde6c6f7342c5b552/charset_normalizer-3.4.9-cp39-cp39-win_amd64.whl", hash = "sha256:ddf4af30b417d9fe16481e9b81c27ab2a7cde1ff7ba3e85653b02db7d145dc7b", size = 156413, upload-time = "2026-07-07T14:34:54.117Z" }, - { url = "https://files.pythonhosted.org/packages/28/e9/9fb6099b868c82a40698a748ae0fbd4f31ccc13844c176a07158ba2abbfd/charset_normalizer-3.4.9-cp39-cp39-win_arm64.whl", hash = "sha256:476743fe6dfe14a2da12e3ac79125dc84a3b2cf8094369a47a1529b0cd8549fe", size = 147887, upload-time = "2026-07-07T14:34:55.51Z" }, { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, ] -[[package]] -name = "click" -version = "8.1.8" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.9.12' and python_full_version < '3.10'", - "python_full_version > '3.9' and python_full_version < '3.9.12'", - "python_full_version <= '3.9'", -] -dependencies = [ - { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, -] - [[package]] name = "click" version = "8.4.2" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } wheels = [ @@ -821,136 +462,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] -[[package]] -name = "coverage" -version = "7.10.7" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.9.12' and python_full_version < '3.10'", - "python_full_version > '3.9' and python_full_version < '3.9.12'", - "python_full_version <= '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/6c/3a3f7a46888e69d18abe3ccc6fe4cb16cccb1e6a2f99698931dafca489e6/coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a", size = 217987, upload-time = "2025-09-21T20:00:57.218Z" }, - { url = "https://files.pythonhosted.org/packages/03/94/952d30f180b1a916c11a56f5c22d3535e943aa22430e9e3322447e520e1c/coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5", size = 218388, upload-time = "2025-09-21T20:01:00.081Z" }, - { url = "https://files.pythonhosted.org/packages/50/2b/9e0cf8ded1e114bcd8b2fd42792b57f1c4e9e4ea1824cde2af93a67305be/coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17", size = 245148, upload-time = "2025-09-21T20:01:01.768Z" }, - { url = "https://files.pythonhosted.org/packages/19/20/d0384ac06a6f908783d9b6aa6135e41b093971499ec488e47279f5b846e6/coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b", size = 246958, upload-time = "2025-09-21T20:01:03.355Z" }, - { url = "https://files.pythonhosted.org/packages/60/83/5c283cff3d41285f8eab897651585db908a909c572bdc014bcfaf8a8b6ae/coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87", size = 248819, upload-time = "2025-09-21T20:01:04.968Z" }, - { url = "https://files.pythonhosted.org/packages/60/22/02eb98fdc5ff79f423e990d877693e5310ae1eab6cb20ae0b0b9ac45b23b/coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e", size = 245754, upload-time = "2025-09-21T20:01:06.321Z" }, - { url = "https://files.pythonhosted.org/packages/b4/bc/25c83bcf3ad141b32cd7dc45485ef3c01a776ca3aa8ef0a93e77e8b5bc43/coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e", size = 246860, upload-time = "2025-09-21T20:01:07.605Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b7/95574702888b58c0928a6e982038c596f9c34d52c5e5107f1eef729399b5/coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df", size = 244877, upload-time = "2025-09-21T20:01:08.829Z" }, - { url = "https://files.pythonhosted.org/packages/47/b6/40095c185f235e085df0e0b158f6bd68cc6e1d80ba6c7721dc81d97ec318/coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0", size = 245108, upload-time = "2025-09-21T20:01:10.527Z" }, - { url = "https://files.pythonhosted.org/packages/c8/50/4aea0556da7a4b93ec9168420d170b55e2eb50ae21b25062513d020c6861/coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13", size = 245752, upload-time = "2025-09-21T20:01:11.857Z" }, - { url = "https://files.pythonhosted.org/packages/6a/28/ea1a84a60828177ae3b100cb6723838523369a44ec5742313ed7db3da160/coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b", size = 220497, upload-time = "2025-09-21T20:01:13.459Z" }, - { url = "https://files.pythonhosted.org/packages/fc/1a/a81d46bbeb3c3fd97b9602ebaa411e076219a150489bcc2c025f151bd52d/coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807", size = 221392, upload-time = "2025-09-21T20:01:14.722Z" }, - { url = "https://files.pythonhosted.org/packages/d2/5d/c1a17867b0456f2e9ce2d8d4708a4c3a089947d0bec9c66cdf60c9e7739f/coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59", size = 218102, upload-time = "2025-09-21T20:01:16.089Z" }, - { url = "https://files.pythonhosted.org/packages/54/f0/514dcf4b4e3698b9a9077f084429681bf3aad2b4a72578f89d7f643eb506/coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a", size = 218505, upload-time = "2025-09-21T20:01:17.788Z" }, - { url = "https://files.pythonhosted.org/packages/20/f6/9626b81d17e2a4b25c63ac1b425ff307ecdeef03d67c9a147673ae40dc36/coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699", size = 248898, upload-time = "2025-09-21T20:01:19.488Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ef/bd8e719c2f7417ba03239052e099b76ea1130ac0cbb183ee1fcaa58aaff3/coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d", size = 250831, upload-time = "2025-09-21T20:01:20.817Z" }, - { url = "https://files.pythonhosted.org/packages/a5/b6/bf054de41ec948b151ae2b79a55c107f5760979538f5fb80c195f2517718/coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e", size = 252937, upload-time = "2025-09-21T20:01:22.171Z" }, - { url = "https://files.pythonhosted.org/packages/0f/e5/3860756aa6f9318227443c6ce4ed7bf9e70bb7f1447a0353f45ac5c7974b/coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23", size = 249021, upload-time = "2025-09-21T20:01:23.907Z" }, - { url = "https://files.pythonhosted.org/packages/26/0f/bd08bd042854f7fd07b45808927ebcce99a7ed0f2f412d11629883517ac2/coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab", size = 250626, upload-time = "2025-09-21T20:01:25.721Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a7/4777b14de4abcc2e80c6b1d430f5d51eb18ed1d75fca56cbce5f2db9b36e/coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82", size = 248682, upload-time = "2025-09-21T20:01:27.105Z" }, - { url = "https://files.pythonhosted.org/packages/34/72/17d082b00b53cd45679bad682fac058b87f011fd8b9fe31d77f5f8d3a4e4/coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2", size = 248402, upload-time = "2025-09-21T20:01:28.629Z" }, - { url = "https://files.pythonhosted.org/packages/81/7a/92367572eb5bdd6a84bfa278cc7e97db192f9f45b28c94a9ca1a921c3577/coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61", size = 249320, upload-time = "2025-09-21T20:01:30.004Z" }, - { url = "https://files.pythonhosted.org/packages/2f/88/a23cc185f6a805dfc4fdf14a94016835eeb85e22ac3a0e66d5e89acd6462/coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14", size = 220536, upload-time = "2025-09-21T20:01:32.184Z" }, - { url = "https://files.pythonhosted.org/packages/fe/ef/0b510a399dfca17cec7bc2f05ad8bd78cf55f15c8bc9a73ab20c5c913c2e/coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2", size = 221425, upload-time = "2025-09-21T20:01:33.557Z" }, - { url = "https://files.pythonhosted.org/packages/51/7f/023657f301a276e4ba1850f82749bc136f5a7e8768060c2e5d9744a22951/coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a", size = 220103, upload-time = "2025-09-21T20:01:34.929Z" }, - { url = "https://files.pythonhosted.org/packages/13/e4/eb12450f71b542a53972d19117ea5a5cea1cab3ac9e31b0b5d498df1bd5a/coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417", size = 218290, upload-time = "2025-09-21T20:01:36.455Z" }, - { url = "https://files.pythonhosted.org/packages/37/66/593f9be12fc19fb36711f19a5371af79a718537204d16ea1d36f16bd78d2/coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973", size = 218515, upload-time = "2025-09-21T20:01:37.982Z" }, - { url = "https://files.pythonhosted.org/packages/66/80/4c49f7ae09cafdacc73fbc30949ffe77359635c168f4e9ff33c9ebb07838/coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c", size = 250020, upload-time = "2025-09-21T20:01:39.617Z" }, - { url = "https://files.pythonhosted.org/packages/a6/90/a64aaacab3b37a17aaedd83e8000142561a29eb262cede42d94a67f7556b/coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7", size = 252769, upload-time = "2025-09-21T20:01:41.341Z" }, - { url = "https://files.pythonhosted.org/packages/98/2e/2dda59afd6103b342e096f246ebc5f87a3363b5412609946c120f4e7750d/coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6", size = 253901, upload-time = "2025-09-21T20:01:43.042Z" }, - { url = "https://files.pythonhosted.org/packages/53/dc/8d8119c9051d50f3119bb4a75f29f1e4a6ab9415cd1fa8bf22fcc3fb3b5f/coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59", size = 250413, upload-time = "2025-09-21T20:01:44.469Z" }, - { url = "https://files.pythonhosted.org/packages/98/b3/edaff9c5d79ee4d4b6d3fe046f2b1d799850425695b789d491a64225d493/coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b", size = 251820, upload-time = "2025-09-21T20:01:45.915Z" }, - { url = "https://files.pythonhosted.org/packages/11/25/9a0728564bb05863f7e513e5a594fe5ffef091b325437f5430e8cfb0d530/coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a", size = 249941, upload-time = "2025-09-21T20:01:47.296Z" }, - { url = "https://files.pythonhosted.org/packages/e0/fd/ca2650443bfbef5b0e74373aac4df67b08180d2f184b482c41499668e258/coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb", size = 249519, upload-time = "2025-09-21T20:01:48.73Z" }, - { url = "https://files.pythonhosted.org/packages/24/79/f692f125fb4299b6f963b0745124998ebb8e73ecdfce4ceceb06a8c6bec5/coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1", size = 251375, upload-time = "2025-09-21T20:01:50.529Z" }, - { url = "https://files.pythonhosted.org/packages/5e/75/61b9bbd6c7d24d896bfeec57acba78e0f8deac68e6baf2d4804f7aae1f88/coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256", size = 220699, upload-time = "2025-09-21T20:01:51.941Z" }, - { url = "https://files.pythonhosted.org/packages/ca/f3/3bf7905288b45b075918d372498f1cf845b5b579b723c8fd17168018d5f5/coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba", size = 221512, upload-time = "2025-09-21T20:01:53.481Z" }, - { url = "https://files.pythonhosted.org/packages/5c/44/3e32dbe933979d05cf2dac5e697c8599cfe038aaf51223ab901e208d5a62/coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf", size = 220147, upload-time = "2025-09-21T20:01:55.2Z" }, - { url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320, upload-time = "2025-09-21T20:01:56.629Z" }, - { url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575, upload-time = "2025-09-21T20:01:58.203Z" }, - { url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568, upload-time = "2025-09-21T20:01:59.748Z" }, - { url = "https://files.pythonhosted.org/packages/a2/77/8c6d22bf61921a59bce5471c2f1f7ac30cd4ac50aadde72b8c48d5727902/coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b", size = 252174, upload-time = "2025-09-21T20:02:01.192Z" }, - { url = "https://files.pythonhosted.org/packages/b1/20/b6ea4f69bbb52dac0aebd62157ba6a9dddbfe664f5af8122dac296c3ee15/coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49", size = 253447, upload-time = "2025-09-21T20:02:02.701Z" }, - { url = "https://files.pythonhosted.org/packages/f9/28/4831523ba483a7f90f7b259d2018fef02cb4d5b90bc7c1505d6e5a84883c/coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911", size = 249779, upload-time = "2025-09-21T20:02:04.185Z" }, - { url = "https://files.pythonhosted.org/packages/a7/9f/4331142bc98c10ca6436d2d620c3e165f31e6c58d43479985afce6f3191c/coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0", size = 251604, upload-time = "2025-09-21T20:02:06.034Z" }, - { url = "https://files.pythonhosted.org/packages/ce/60/bda83b96602036b77ecf34e6393a3836365481b69f7ed7079ab85048202b/coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f", size = 249497, upload-time = "2025-09-21T20:02:07.619Z" }, - { url = "https://files.pythonhosted.org/packages/5f/af/152633ff35b2af63977edd835d8e6430f0caef27d171edf2fc76c270ef31/coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c", size = 249350, upload-time = "2025-09-21T20:02:10.34Z" }, - { url = "https://files.pythonhosted.org/packages/9d/71/d92105d122bd21cebba877228990e1646d862e34a98bb3374d3fece5a794/coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f", size = 251111, upload-time = "2025-09-21T20:02:12.122Z" }, - { url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746, upload-time = "2025-09-21T20:02:13.919Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541, upload-time = "2025-09-21T20:02:15.57Z" }, - { url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170, upload-time = "2025-09-21T20:02:17.395Z" }, - { url = "https://files.pythonhosted.org/packages/bb/22/e04514bf2a735d8b0add31d2b4ab636fc02370730787c576bb995390d2d5/coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c", size = 219029, upload-time = "2025-09-21T20:02:18.936Z" }, - { url = "https://files.pythonhosted.org/packages/11/0b/91128e099035ece15da3445d9015e4b4153a6059403452d324cbb0a575fa/coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15", size = 219259, upload-time = "2025-09-21T20:02:20.44Z" }, - { url = "https://files.pythonhosted.org/packages/8b/51/66420081e72801536a091a0c8f8c1f88a5c4bf7b9b1bdc6222c7afe6dc9b/coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4", size = 260592, upload-time = "2025-09-21T20:02:22.313Z" }, - { url = "https://files.pythonhosted.org/packages/5d/22/9b8d458c2881b22df3db5bb3e7369e63d527d986decb6c11a591ba2364f7/coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0", size = 262768, upload-time = "2025-09-21T20:02:24.287Z" }, - { url = "https://files.pythonhosted.org/packages/f7/08/16bee2c433e60913c610ea200b276e8eeef084b0d200bdcff69920bd5828/coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0", size = 264995, upload-time = "2025-09-21T20:02:26.133Z" }, - { url = "https://files.pythonhosted.org/packages/20/9d/e53eb9771d154859b084b90201e5221bca7674ba449a17c101a5031d4054/coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65", size = 259546, upload-time = "2025-09-21T20:02:27.716Z" }, - { url = "https://files.pythonhosted.org/packages/ad/b0/69bc7050f8d4e56a89fb550a1577d5d0d1db2278106f6f626464067b3817/coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541", size = 262544, upload-time = "2025-09-21T20:02:29.216Z" }, - { url = "https://files.pythonhosted.org/packages/ef/4b/2514b060dbd1bc0aaf23b852c14bb5818f244c664cb16517feff6bb3a5ab/coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6", size = 260308, upload-time = "2025-09-21T20:02:31.226Z" }, - { url = "https://files.pythonhosted.org/packages/54/78/7ba2175007c246d75e496f64c06e94122bdb914790a1285d627a918bd271/coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999", size = 258920, upload-time = "2025-09-21T20:02:32.823Z" }, - { url = "https://files.pythonhosted.org/packages/c0/b3/fac9f7abbc841409b9a410309d73bfa6cfb2e51c3fada738cb607ce174f8/coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2", size = 261434, upload-time = "2025-09-21T20:02:34.86Z" }, - { url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403, upload-time = "2025-09-21T20:02:37.034Z" }, - { url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469, upload-time = "2025-09-21T20:02:39.011Z" }, - { url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731, upload-time = "2025-09-21T20:02:40.939Z" }, - { url = "https://files.pythonhosted.org/packages/23/9c/5844ab4ca6a4dd97a1850e030a15ec7d292b5c5cb93082979225126e35dd/coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520", size = 218302, upload-time = "2025-09-21T20:02:42.527Z" }, - { url = "https://files.pythonhosted.org/packages/f0/89/673f6514b0961d1f0e20ddc242e9342f6da21eaba3489901b565c0689f34/coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32", size = 218578, upload-time = "2025-09-21T20:02:44.468Z" }, - { url = "https://files.pythonhosted.org/packages/05/e8/261cae479e85232828fb17ad536765c88dd818c8470aca690b0ac6feeaa3/coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f", size = 249629, upload-time = "2025-09-21T20:02:46.503Z" }, - { url = "https://files.pythonhosted.org/packages/82/62/14ed6546d0207e6eda876434e3e8475a3e9adbe32110ce896c9e0c06bb9a/coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a", size = 252162, upload-time = "2025-09-21T20:02:48.689Z" }, - { url = "https://files.pythonhosted.org/packages/ff/49/07f00db9ac6478e4358165a08fb41b469a1b053212e8a00cb02f0d27a05f/coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360", size = 253517, upload-time = "2025-09-21T20:02:50.31Z" }, - { url = "https://files.pythonhosted.org/packages/a2/59/c5201c62dbf165dfbc91460f6dbbaa85a8b82cfa6131ac45d6c1bfb52deb/coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69", size = 249632, upload-time = "2025-09-21T20:02:51.971Z" }, - { url = "https://files.pythonhosted.org/packages/07/ae/5920097195291a51fb00b3a70b9bbd2edbfe3c84876a1762bd1ef1565ebc/coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14", size = 251520, upload-time = "2025-09-21T20:02:53.858Z" }, - { url = "https://files.pythonhosted.org/packages/b9/3c/a815dde77a2981f5743a60b63df31cb322c944843e57dbd579326625a413/coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe", size = 249455, upload-time = "2025-09-21T20:02:55.807Z" }, - { url = "https://files.pythonhosted.org/packages/aa/99/f5cdd8421ea656abefb6c0ce92556709db2265c41e8f9fc6c8ae0f7824c9/coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e", size = 249287, upload-time = "2025-09-21T20:02:57.784Z" }, - { url = "https://files.pythonhosted.org/packages/c3/7a/e9a2da6a1fc5d007dd51fca083a663ab930a8c4d149c087732a5dbaa0029/coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd", size = 250946, upload-time = "2025-09-21T20:02:59.431Z" }, - { url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009, upload-time = "2025-09-21T20:03:01.324Z" }, - { url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804, upload-time = "2025-09-21T20:03:03.4Z" }, - { url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384, upload-time = "2025-09-21T20:03:05.111Z" }, - { url = "https://files.pythonhosted.org/packages/62/09/9a5608d319fa3eba7a2019addeacb8c746fb50872b57a724c9f79f146969/coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63", size = 219047, upload-time = "2025-09-21T20:03:06.795Z" }, - { url = "https://files.pythonhosted.org/packages/f5/6f/f58d46f33db9f2e3647b2d0764704548c184e6f5e014bef528b7f979ef84/coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2", size = 219266, upload-time = "2025-09-21T20:03:08.495Z" }, - { url = "https://files.pythonhosted.org/packages/74/5c/183ffc817ba68e0b443b8c934c8795553eb0c14573813415bd59941ee165/coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d", size = 260767, upload-time = "2025-09-21T20:03:10.172Z" }, - { url = "https://files.pythonhosted.org/packages/0f/48/71a8abe9c1ad7e97548835e3cc1adbf361e743e9d60310c5f75c9e7bf847/coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0", size = 262931, upload-time = "2025-09-21T20:03:11.861Z" }, - { url = "https://files.pythonhosted.org/packages/84/fd/193a8fb132acfc0a901f72020e54be5e48021e1575bb327d8ee1097a28fd/coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699", size = 265186, upload-time = "2025-09-21T20:03:13.539Z" }, - { url = "https://files.pythonhosted.org/packages/b1/8f/74ecc30607dd95ad50e3034221113ccb1c6d4e8085cc761134782995daae/coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9", size = 259470, upload-time = "2025-09-21T20:03:15.584Z" }, - { url = "https://files.pythonhosted.org/packages/0f/55/79ff53a769f20d71b07023ea115c9167c0bb56f281320520cf64c5298a96/coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f", size = 262626, upload-time = "2025-09-21T20:03:17.673Z" }, - { url = "https://files.pythonhosted.org/packages/88/e2/dac66c140009b61ac3fc13af673a574b00c16efdf04f9b5c740703e953c0/coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1", size = 260386, upload-time = "2025-09-21T20:03:19.36Z" }, - { url = "https://files.pythonhosted.org/packages/a2/f1/f48f645e3f33bb9ca8a496bc4a9671b52f2f353146233ebd7c1df6160440/coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0", size = 258852, upload-time = "2025-09-21T20:03:21.007Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3b/8442618972c51a7affeead957995cfa8323c0c9bcf8fa5a027421f720ff4/coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399", size = 261534, upload-time = "2025-09-21T20:03:23.12Z" }, - { url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784, upload-time = "2025-09-21T20:03:24.769Z" }, - { url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905, upload-time = "2025-09-21T20:03:26.93Z" }, - { url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922, upload-time = "2025-09-21T20:03:28.672Z" }, - { url = "https://files.pythonhosted.org/packages/a3/ad/d1c25053764b4c42eb294aae92ab617d2e4f803397f9c7c8295caa77a260/coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3", size = 217978, upload-time = "2025-09-21T20:03:30.362Z" }, - { url = "https://files.pythonhosted.org/packages/52/2f/b9f9daa39b80ece0b9548bbb723381e29bc664822d9a12c2135f8922c22b/coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c", size = 218370, upload-time = "2025-09-21T20:03:32.147Z" }, - { url = "https://files.pythonhosted.org/packages/dd/6e/30d006c3b469e58449650642383dddf1c8fb63d44fdf92994bfd46570695/coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396", size = 244802, upload-time = "2025-09-21T20:03:33.919Z" }, - { url = "https://files.pythonhosted.org/packages/b0/49/8a070782ce7e6b94ff6a0b6d7c65ba6bc3091d92a92cef4cd4eb0767965c/coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40", size = 246625, upload-time = "2025-09-21T20:03:36.09Z" }, - { url = "https://files.pythonhosted.org/packages/6a/92/1c1c5a9e8677ce56d42b97bdaca337b2d4d9ebe703d8c174ede52dbabd5f/coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594", size = 248399, upload-time = "2025-09-21T20:03:38.342Z" }, - { url = "https://files.pythonhosted.org/packages/c0/54/b140edee7257e815de7426d5d9846b58505dffc29795fff2dfb7f8a1c5a0/coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a", size = 245142, upload-time = "2025-09-21T20:03:40.591Z" }, - { url = "https://files.pythonhosted.org/packages/e4/9e/6d6b8295940b118e8b7083b29226c71f6154f7ff41e9ca431f03de2eac0d/coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b", size = 246284, upload-time = "2025-09-21T20:03:42.355Z" }, - { url = "https://files.pythonhosted.org/packages/db/e5/5e957ca747d43dbe4d9714358375c7546cb3cb533007b6813fc20fce37ad/coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3", size = 244353, upload-time = "2025-09-21T20:03:44.218Z" }, - { url = "https://files.pythonhosted.org/packages/9a/45/540fc5cc92536a1b783b7ef99450bd55a4b3af234aae35a18a339973ce30/coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0", size = 244430, upload-time = "2025-09-21T20:03:46.065Z" }, - { url = "https://files.pythonhosted.org/packages/75/0b/8287b2e5b38c8fe15d7e3398849bb58d382aedc0864ea0fa1820e8630491/coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f", size = 245311, upload-time = "2025-09-21T20:03:48.19Z" }, - { url = "https://files.pythonhosted.org/packages/0c/1d/29724999984740f0c86d03e6420b942439bf5bd7f54d4382cae386a9d1e9/coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431", size = 220500, upload-time = "2025-09-21T20:03:50.024Z" }, - { url = "https://files.pythonhosted.org/packages/43/11/4b1e6b129943f905ca54c339f343877b55b365ae2558806c1be4f7476ed5/coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07", size = 221408, upload-time = "2025-09-21T20:03:51.803Z" }, - { url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" }, -] - -[package.optional-dependencies] -toml = [ - { name = "tomli", marker = "python_full_version < '3.10'" }, -] - [[package]] name = "coverage" version = "7.15.3" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] sdist = { url = "https://files.pythonhosted.org/packages/f4/45/78dbf9604ee5b3db24efbf26bed1cb58862fb40480cba821963c69348751/coverage-7.15.3.tar.gz", hash = "sha256:ae7ea5a4614acf399ef0483c4cb34f8f8f01df848d8fcbe7d3ce0865733f1c4d", size = 935592, upload-time = "2026-08-02T18:50:17.006Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/90/d9/01d8e19b2c0e55903bfb540c9f6bd32326f1d5b2fcb5a7dd8648ae2dd9c5/coverage-7.15.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3a82b2ceee91ba353e59fe2436d8a9eae799ff9825e5385423ea205d693e2949", size = 222202, upload-time = "2026-08-02T18:47:25.951Z" }, @@ -1047,7 +562,7 @@ wheels = [ [package.optional-dependencies] toml = [ - { name = "tomli", marker = "python_full_version >= '3.10' and python_full_version <= '3.11'" }, + { name = "tomli", marker = "python_full_version <= '3.11'" }, ] [[package]] @@ -1146,21 +661,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/85/3be6f0a1398f976070672be64f61895f8839857618a2d8cc0d3ab529d3dc/cramjam-2.11.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:d388bd5723732c3afe1dd1d181e4213cc4e1be210b080572e7d5749f6e955656", size = 2160229, upload-time = "2025-07-27T21:24:06.729Z" }, { url = "https://files.pythonhosted.org/packages/57/5e/66cfc3635511b20014bbb3f2ecf0095efb3049e9e96a4a9e478e4f3d7b78/cramjam-2.11.0-cp314-cp314t-win32.whl", hash = "sha256:0a70ff17f8e1d13f322df616505550f0f4c39eda62290acb56f069d4857037c8", size = 1610267, upload-time = "2025-07-27T21:24:08.428Z" }, { url = "https://files.pythonhosted.org/packages/ce/c6/c71e82e041c95ffe6a92ac707785500aa2a515a4339c2c7dd67e3c449249/cramjam-2.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:028400d699442d40dbda02f74158c73d05cb76587a12490d0bfedd958fd49188", size = 1713108, upload-time = "2025-07-27T21:24:10.147Z" }, - { url = "https://files.pythonhosted.org/packages/8c/33/3d7a7fbfb313614d59ae2e512b9dacfc22efb07c20e4af7deb73d3409f7b/cramjam-2.11.0-cp39-cp39-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:2581e82dca742b55d8b1d7f33892394c06b057a74f2853ffcb0802dcddcbf694", size = 3559843, upload-time = "2025-07-27T21:24:11.928Z" }, - { url = "https://files.pythonhosted.org/packages/d4/b0/ccf09697df7fcc750c4913dc4bf3fb91e5b778dda65fb9fa55dde61c03dc/cramjam-2.11.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a9994a42cd12f07ece04eff94dbf6e127b3986f7af9b26db1eb4545c477a6604", size = 1862081, upload-time = "2025-07-27T21:24:13.8Z" }, - { url = "https://files.pythonhosted.org/packages/41/55/d36255f1a9004a3352469143d2b8a5b769e0eb4e484a8192da41ad67e893/cramjam-2.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a4963dac24213690183110d6b41125fdc4af871a5a213589d6c6606d49e1b949", size = 1699970, upload-time = "2025-07-27T21:24:15.547Z" }, - { url = "https://files.pythonhosted.org/packages/35/52/722a2efbe104903648185411f9c634e5678035476bc556001d6ef811e191/cramjam-2.11.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c9af16f0b07d851b968c54e52d19430d820bb47c26d10a09cfb5c7127de26773", size = 2025715, upload-time = "2025-07-27T21:24:17.327Z" }, - { url = "https://files.pythonhosted.org/packages/0a/60/75084f30277d5f2481d20a544654894a32528f98f4415c1bd467823ab5b2/cramjam-2.11.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e2400c09ba620e2ca91a903dbe907d75f6a1994d8337e9f3026778daa92b08d", size = 1766999, upload-time = "2025-07-27T21:24:19.163Z" }, - { url = "https://files.pythonhosted.org/packages/89/5c/2663bdfcea6ab06fcac97883b5b574a12236c5d9f70691cc05dd49cb10fb/cramjam-2.11.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b820004db8b22715cee2ef154d4b47b3d76c4677ff217c587dd46f694a3052f9", size = 1854352, upload-time = "2025-07-27T21:24:20.953Z" }, - { url = "https://files.pythonhosted.org/packages/b4/df/1db5b57ccf77e923687b2061766e69c2cbdaf41641204207dbf55ef7ebe9/cramjam-2.11.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:261e9200942189d8201a005ffa1e29339479364b5b0013ab0758b03229d9ac67", size = 2036219, upload-time = "2025-07-27T21:24:23.029Z" }, - { url = "https://files.pythonhosted.org/packages/f7/28/fa3b017668a3264068c893e57a6b923dfd8fa851a1c821c4cc1c95cd47a6/cramjam-2.11.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24c61f1fad56ca68aee53bf67b6a84cd762a2c71ee4b71064378547c2411ae6", size = 2077245, upload-time = "2025-07-27T21:24:25.127Z" }, - { url = "https://files.pythonhosted.org/packages/d1/1d/6f6018ee81acec6c4ef6cda6bd0770959992caf2f1c41e7944a135a53eca/cramjam-2.11.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab86d22f69a21961f35d1a1b02278b5bb9a95c5f5b4722c6904bca343c8d219f", size = 1982235, upload-time = "2025-07-27T21:24:26.851Z" }, - { url = "https://files.pythonhosted.org/packages/31/b4/c38f6077d8ec7c9208d23d4f7f19a618f5b4940170c9deba5d3bdc722eb6/cramjam-2.11.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a88bc9b191422cd5b22a1521b28607008590628b6b2a8a7db5c54ec04dc82fa1", size = 2034629, upload-time = "2025-07-27T21:24:28.694Z" }, - { url = "https://files.pythonhosted.org/packages/66/3b/3f46a349b1a7a67e2bda10e99403e9163c87c95e34399cc69f4f86a2461a/cramjam-2.11.0-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:7855bc4df5ed5f7fb1c98ea3fd98292e9acd3c097b1b21d596a69e1e60455400", size = 2155552, upload-time = "2025-07-27T21:24:30.572Z" }, - { url = "https://files.pythonhosted.org/packages/ed/86/b431a51162d4c8f33b28bdcca047382e1038757d43625e65c8d29ed6c31f/cramjam-2.11.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:19eb43e21db9dc42613599703c1a8e40b0170514a313f11f4c8be380425a1019", size = 2169651, upload-time = "2025-07-27T21:24:32.331Z" }, - { url = "https://files.pythonhosted.org/packages/6e/d5/9aa69784da58b6bd3f5abcaad2eb76ad2a89efde7929821bad17355fd8da/cramjam-2.11.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:cec977d673ad596bae6bdfc0091ee386cef05b515b23f2ce52f9fadd0156186a", size = 2159740, upload-time = "2025-07-27T21:24:34.108Z" }, - { url = "https://files.pythonhosted.org/packages/9c/e1/75706936eb81605a939e15b8b7a1241b35e805ce76a64838b4586c440f61/cramjam-2.11.0-cp39-cp39-win32.whl", hash = "sha256:dcc3b15b97f3054964b47e2a5fcfb4f5ff569e9af0a7af19f1d4c5f4231bbf3b", size = 1605449, upload-time = "2025-07-27T21:24:36.538Z" }, - { url = "https://files.pythonhosted.org/packages/37/6b/ae7626994c7285bfc0ffa0d9929c3c16f2d0aea5b9e151dad82fd0616762/cramjam-2.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:5eb0603d8f8019451fc00e1daf4022dfc9df59c16d2e68f925c77ac94555493b", size = 1710860, upload-time = "2025-07-27T21:24:38.243Z" }, { url = "https://files.pythonhosted.org/packages/bf/8f/82e35ec3c5387f1864f46b3c24bce89a07af8bb3ef242ae47281db2c1848/cramjam-2.11.0-pp310-pypy310_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:37bed927abc4a7ae2d2669baa3675e21904d8a038ed8e4313326ea7b3be62b2b", size = 3573104, upload-time = "2025-07-27T21:24:40.069Z" }, { url = "https://files.pythonhosted.org/packages/f0/4e/0c821918080a32ba1e52c040e12dd02dada67728f07305c5f778b808a807/cramjam-2.11.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:50e4a58635fa8c6897d84847d6e065eb69f92811670fc5e9f2d9e3b6279a02b6", size = 1873441, upload-time = "2025-07-27T21:24:42.333Z" }, { url = "https://files.pythonhosted.org/packages/a8/fd/848d077bf6abc4ce84273d8e3f3a70d61a2240519a339462f699d8acf829/cramjam-2.11.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:3d1ba626dd5f81f7f09bbf59f70b534e2b75e0d6582b056b7bd31b397f1c13e9", size = 1702589, upload-time = "2025-07-27T21:24:44.305Z" }, @@ -1175,84 +675,13 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/90/20d1747255f1ee69a412e319da51ea594c18cca195e7a4d4c713f045eff5/cramjam-2.11.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6c2eea545fef1065c7dd4eda991666fd9c783fbc1d226592ccca8d8891c02f23", size = 1714982, upload-time = "2025-07-27T21:25:05.79Z" }, ] -[[package]] -name = "cryptography" -version = "47.0.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version <= '3.9'", -] -dependencies = [ - { name = "cffi", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.9' and platform_python_implementation != 'PyPy'" }, - { name = "typing-extensions", marker = "python_full_version <= '3.9'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/98/40dfe932134bdcae4f6ab5927c87488754bf9eb79297d7e0070b78dd58e9/cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", size = 7912214, upload-time = "2026-04-24T19:53:03.864Z" }, - { url = "https://files.pythonhosted.org/packages/34/c6/2733531243fba725f58611b918056b277692f1033373dcc8bd01af1c05d4/cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", size = 4644617, upload-time = "2026-04-24T19:53:06.909Z" }, - { url = "https://files.pythonhosted.org/packages/00/e3/b27be1a670a9b87f855d211cf0e1174a5d721216b7616bd52d8581d912ed/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", size = 4668186, upload-time = "2026-04-24T19:53:09.053Z" }, - { url = "https://files.pythonhosted.org/packages/81/b9/8443cfe5d17d482d348cee7048acf502bb89a51b6382f06240fd290d4ca3/cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", size = 4651244, upload-time = "2026-04-24T19:53:11.217Z" }, - { url = "https://files.pythonhosted.org/packages/5d/5e/13ed0cdd0eb88ba159d6dd5ebfece8cb901dbcf1ae5ac4072e28b55d3153/cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", size = 5252906, upload-time = "2026-04-24T19:53:13.532Z" }, - { url = "https://files.pythonhosted.org/packages/64/16/ed058e1df0f33d440217cd120d41d5dda9dd215a80b8187f68483185af82/cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", size = 4701842, upload-time = "2026-04-24T19:53:15.618Z" }, - { url = "https://files.pythonhosted.org/packages/02/e0/3d30986b30fdbd9e969abbdf8ba00ed0618615144341faeb57f395a084fe/cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", size = 4289313, upload-time = "2026-04-24T19:53:17.755Z" }, - { url = "https://files.pythonhosted.org/packages/df/fd/32db38e3ad0cb331f0691cb4c7a8a6f176f679124dee746b3af6633db4d9/cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", size = 4650964, upload-time = "2026-04-24T19:53:20.062Z" }, - { url = "https://files.pythonhosted.org/packages/86/53/5395d944dfd48cb1f67917f533c609c34347185ef15eb4308024c876f274/cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", size = 5207817, upload-time = "2026-04-24T19:53:22.498Z" }, - { url = "https://files.pythonhosted.org/packages/34/4f/e5711b28e1901f7d480a2b1b688b645aa4c77c73f10731ed17e7f7db3f0d/cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", size = 4701544, upload-time = "2026-04-24T19:53:24.356Z" }, - { url = "https://files.pythonhosted.org/packages/22/22/c8ddc25de3010fc8da447648f5a092c40e7a8fadf01dd6d255d9c0b9373d/cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", size = 4783536, upload-time = "2026-04-24T19:53:26.665Z" }, - { url = "https://files.pythonhosted.org/packages/66/b6/d4a68f4ea999c6d89e8498579cba1c5fcba4276284de7773b17e4fa69293/cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", size = 4926106, upload-time = "2026-04-24T19:53:28.686Z" }, - { url = "https://files.pythonhosted.org/packages/54/ed/5f524db1fade9c013aa618e1c99c6ed05e8ffc9ceee6cda22fed22dda3f4/cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", size = 3258581, upload-time = "2026-04-24T19:53:31.058Z" }, - { url = "https://files.pythonhosted.org/packages/b2/dc/1b901990b174786569029f67542b3edf72ac068b6c3c8683c17e6a2f5363/cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", size = 3775309, upload-time = "2026-04-24T19:53:33.054Z" }, - { url = "https://files.pythonhosted.org/packages/14/88/7aa18ad9c11bc87689affa5ce4368d884b517502d75739d475fc6f4a03c7/cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0", size = 7904299, upload-time = "2026-04-24T19:53:35.003Z" }, - { url = "https://files.pythonhosted.org/packages/07/55/c18f75724544872f234678fdedc871391722cb34a2aee19faa9f63100bb2/cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7", size = 4631180, upload-time = "2026-04-24T19:53:37.517Z" }, - { url = "https://files.pythonhosted.org/packages/ee/65/31a5cc0eaca99cec5bafffe155d407115d96136bb161e8b49e0ef73f09a7/cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1", size = 4653529, upload-time = "2026-04-24T19:53:39.775Z" }, - { url = "https://files.pythonhosted.org/packages/e5/bc/641c0519a495f3bfd0421b48d7cd325c4336578523ccd76ea322b6c29c7a/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c", size = 4638570, upload-time = "2026-04-24T19:53:42.129Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f2/300327b0a47f6dc94dd8b71b57052aefe178bb51745073d73d80604f11ab/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829", size = 5238019, upload-time = "2026-04-24T19:53:44.577Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5a/5b5cf994391d4bf9d9c7efd4c66aabe4d95227256627f8fea6cff7dfadbd/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7", size = 4686832, upload-time = "2026-04-24T19:53:47.015Z" }, - { url = "https://files.pythonhosted.org/packages/dc/2c/ae950e28fd6475c852fc21a44db3e6b5bcc1261d1e370f2b6e42fa800fef/cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923", size = 4269301, upload-time = "2026-04-24T19:53:48.97Z" }, - { url = "https://files.pythonhosted.org/packages/67/fb/6a39782e150ffe5cc1b0018cb6ddc48bf7ca62b498d7539ffc8a758e977d/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab", size = 4638110, upload-time = "2026-04-24T19:53:51.011Z" }, - { url = "https://files.pythonhosted.org/packages/8e/d7/0b3c71090a76e5c203164a47688b697635ece006dcd2499ab3a4dbd3f0bd/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736", size = 5194988, upload-time = "2026-04-24T19:53:52.962Z" }, - { url = "https://files.pythonhosted.org/packages/63/33/63a961498a9df51721ab578c5a2622661411fc520e00bd83b0cc64eb20c4/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7", size = 4686563, upload-time = "2026-04-24T19:53:55.274Z" }, - { url = "https://files.pythonhosted.org/packages/b7/bf/5ee5b145248f92250de86145d1c1d6edebbd57a7fe7caa4dedb5d4cf06a1/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52", size = 4770094, upload-time = "2026-04-24T19:53:57.753Z" }, - { url = "https://files.pythonhosted.org/packages/92/43/21d220b2da5d517773894dacdcdb5c682c28d3fffce65548cb06e87d5501/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd", size = 4913811, upload-time = "2026-04-24T19:54:00.236Z" }, - { url = "https://files.pythonhosted.org/packages/31/98/dc4ad376ac5f1a1a7d4a83f7b0c6f2bcad36b5d2d8f30aeb482d3a7d9582/cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63", size = 3237158, upload-time = "2026-04-24T19:54:02.606Z" }, - { url = "https://files.pythonhosted.org/packages/bc/da/97f62d18306b5133468bc3f8cc73a3111e8cdc8cf8d3e69474d6e5fd2d1b/cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b", size = 3758706, upload-time = "2026-04-24T19:54:04.433Z" }, - { url = "https://files.pythonhosted.org/packages/e0/34/a4fae8ae7c3bc227460c9ae43f56abf1b911da0ec29e0ebac53bb0a4b6b7/cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", size = 7904072, upload-time = "2026-04-24T19:54:06.411Z" }, - { url = "https://files.pythonhosted.org/packages/01/64/d7b1e54fdb69f22d24a64bb3e88dc718b31c7fb10ef0b9691a3cf7eeea6e/cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", size = 4635767, upload-time = "2026-04-24T19:54:08.519Z" }, - { url = "https://files.pythonhosted.org/packages/8b/7b/cca826391fb2a94efdcdfe4631eb69306ee1cff0b22f664a412c90713877/cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", size = 4654350, upload-time = "2026-04-24T19:54:10.795Z" }, - { url = "https://files.pythonhosted.org/packages/4c/65/4b57bcc823f42a991627c51c2f68c9fd6eb1393c1756aac876cba2accae2/cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", size = 4643394, upload-time = "2026-04-24T19:54:13.275Z" }, - { url = "https://files.pythonhosted.org/packages/f4/c4/2c5fbeea70adbbca2bbae865e1d605d6a4a7f8dbd9d33eaf69645087f06c/cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", size = 5225777, upload-time = "2026-04-24T19:54:15.18Z" }, - { url = "https://files.pythonhosted.org/packages/7e/b8/ac57107ef32749d2b244e36069bb688792a363aaaa3acc9e3cf84c130315/cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", size = 4688771, upload-time = "2026-04-24T19:54:17.835Z" }, - { url = "https://files.pythonhosted.org/packages/56/fc/9f1de22ff8be99d991f240a46863c52d475404c408886c5a38d2b5c3bb26/cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", size = 4270753, upload-time = "2026-04-24T19:54:19.963Z" }, - { url = "https://files.pythonhosted.org/packages/00/68/d70c852797aa68e8e48d12e5a87170c43f67bb4a59403627259dd57d15de/cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", size = 4642911, upload-time = "2026-04-24T19:54:21.818Z" }, - { url = "https://files.pythonhosted.org/packages/a5/51/661cbee74f594c5d97ff82d34f10d5551c085ca4668645f4606ebd22bd5d/cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", size = 5181411, upload-time = "2026-04-24T19:54:24.376Z" }, - { url = "https://files.pythonhosted.org/packages/94/87/f2b6c374a82cf076cfa1416992ac8e8ec94d79facc37aec87c1a5cb72352/cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", size = 4688262, upload-time = "2026-04-24T19:54:26.946Z" }, - { url = "https://files.pythonhosted.org/packages/14/e2/8b7462f4acf21ec509616f0245018bb197194ab0b65c2ea21a0bdd53c0eb/cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", size = 4775506, upload-time = "2026-04-24T19:54:28.926Z" }, - { url = "https://files.pythonhosted.org/packages/70/75/158e494e4c08dc05e039da5bb48553826bd26c23930cf8d3cd5f21fa8921/cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", size = 4912060, upload-time = "2026-04-24T19:54:30.869Z" }, - { url = "https://files.pythonhosted.org/packages/06/bd/0a9d3edbf5eadbac926d7b9b3cd0c4be584eeeae4a003d24d9eda4affbbd/cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", size = 3248487, upload-time = "2026-04-24T19:54:33.494Z" }, - { url = "https://files.pythonhosted.org/packages/60/80/5681af756d0da3a599b7bdb586fac5a1540f1bcefd2717a20e611ddade45/cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", size = 3755737, upload-time = "2026-04-24T19:54:35.408Z" }, - { url = "https://files.pythonhosted.org/packages/1b/a0/928c9ce0d120a40a81aa99e3ba383e87337b9ac9ef9f6db02e4d7822424d/cryptography-47.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f1207974a904e005f762869996cf620e9bf79ecb4622f148550bb48e0eb35a7", size = 3909893, upload-time = "2026-04-24T19:54:38.334Z" }, - { url = "https://files.pythonhosted.org/packages/81/75/d691e284750df5d9569f2b1ce4a00a71e1d79566da83b2b3e5549c84917f/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:1a405c08857258c11016777e11c02bacbe7ef596faf259305d282272a3a05cbe", size = 4587867, upload-time = "2026-04-24T19:54:40.619Z" }, - { url = "https://files.pythonhosted.org/packages/07/d6/1b90f1a4e453009730b4545286f0b39bb348d805c11181fc31544e4f9a65/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:20fdbe3e38fb67c385d233c89371fa27f9909f6ebca1cecc20c13518dae65475", size = 4627192, upload-time = "2026-04-24T19:54:42.849Z" }, - { url = "https://files.pythonhosted.org/packages/dc/53/cb358a80e9e359529f496870dd08c102aa8a4b5b9f9064f00f0d6ed5b527/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f7db373287273d8af1414cf95dc4118b13ffdc62be521997b0f2b270771fef50", size = 4587486, upload-time = "2026-04-24T19:54:44.908Z" }, - { url = "https://files.pythonhosted.org/packages/8b/57/aaa3d53876467a226f9a7a82fd14dd48058ad2de1948493442dfa16e2ffd/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9fe6b7c64926c765f9dff301f9c1b867febcda5768868ca084e18589113732ab", size = 4626327, upload-time = "2026-04-24T19:54:47.813Z" }, - { url = "https://files.pythonhosted.org/packages/ab/9c/51f28c3550276bcf35660703ba0ab829a90b88be8cd98a71ef23c2413913/cryptography-47.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8", size = 3698916, upload-time = "2026-04-24T19:54:49.782Z" }, -] - [[package]] name = "cryptography" version = "50.0.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", - "python_full_version >= '3.9.12' and python_full_version < '3.10'", - "python_full_version > '3.9' and python_full_version < '3.9.12'", -] dependencies = [ - { name = "cffi", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.9' and python_full_version < '3.10' and platform_python_implementation != 'PyPy'" }, - { name = "cffi", version = "2.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and platform_python_implementation != 'PyPy'" }, - { name = "typing-extensions", marker = "python_full_version > '3.9' and python_full_version < '3.11'" }, + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } wheels = [ @@ -1338,10 +767,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4c/ce/6be776814f6cb81751f3da737ed537385738148e1ea99f89fb4637799198/cython-3.2.9-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7d41baea51ea00f9237f75af498577827493bca5e9b45bbd4e351543727e589a", size = 3124337, upload-time = "2026-07-24T06:22:37.868Z" }, { url = "https://files.pythonhosted.org/packages/e2/48/27c948cbbfe6050994e67a497ae530955dcda7e79084319321c49969e0fd/cython-3.2.9-cp39-abi3-win32.whl", hash = "sha256:61d4abbf84f77c8d19361d05d9f51d65d8d95e74f736eae55fa1aed8a1430469", size = 2435609, upload-time = "2026-07-24T06:22:40.005Z" }, { url = "https://files.pythonhosted.org/packages/17/ef/cf0e1bd7542296f1752be63b027f90271448d8c8062eac66d8e44a79b883/cython-3.2.9-cp39-abi3-win_arm64.whl", hash = "sha256:57a6a78d14f7dd7d6062d9bca694e2a8c1c14113b6ceceea076abcd1161fdc5a", size = 2458025, upload-time = "2026-07-24T06:22:41.973Z" }, - { url = "https://files.pythonhosted.org/packages/73/00/10a03030fc623071be0e724c721db179584c699f167505d6adc3f878cf33/cython-3.2.9-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d15c4d4ba8a3ad284124384f769465bfba45512aaa4c81caea8a165f79fc042a", size = 3010547, upload-time = "2026-07-24T06:22:43.917Z" }, - { url = "https://files.pythonhosted.org/packages/e7/fa/7fd69af1d4e049d7abe7ff63c85e10887a4582d4c8c6295fbeda6c9cd882/cython-3.2.9-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f2fe4bc1b8fa4130563b94a3614db9ea17e92a455bed00e6a5d03eaff434b33c", size = 3314503, upload-time = "2026-07-24T06:22:46.115Z" }, - { url = "https://files.pythonhosted.org/packages/64/f0/c5bb19e524c3271f4dae9f581a113caee15f85f0960507952aacdb959297/cython-3.2.9-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c534d782757710e39f3c9582214d7a28a0d68c86e78ee3df1d729cbc270e133", size = 3466488, upload-time = "2026-07-24T06:22:48.326Z" }, - { url = "https://files.pythonhosted.org/packages/8c/36/5eecf542aebbd8d84b0144706b11c94a90876242e3f9592cf7edee6a870e/cython-3.2.9-cp39-cp39-win_amd64.whl", hash = "sha256:c6d4a92a87d238231564b5cbf27a8d1e46b7e62040c66b5ff33778a16b22295e", size = 2791440, upload-time = "2026-07-24T06:22:51.314Z" }, { url = "https://files.pythonhosted.org/packages/00/ec/e61deec9bcfbb0e1b36f8b5ba75cb44644419b4bfd0fdd666bffd21d9579/cython-3.2.9-py3-none-any.whl", hash = "sha256:a2b0e87f6b80790c929308ca0831d686f7a180feab684fe8cd4a4380bd96aaca", size = 1259272, upload-time = "2026-07-24T06:21:18.95Z" }, ] @@ -1475,22 +900,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, - { url = "https://files.pythonhosted.org/packages/c2/59/ae5cdac87a00962122ea37bb346d41b66aec05f9ce328fa2b9e216f8967b/frozenlist-1.8.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d8b7138e5cd0647e4523d6685b0eac5d4be9a184ae9634492f25c6eb38c12a47", size = 86967, upload-time = "2025-10-06T05:37:55.607Z" }, - { url = "https://files.pythonhosted.org/packages/8a/10/17059b2db5a032fd9323c41c39e9d1f5f9d0c8f04d1e4e3e788573086e61/frozenlist-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a6483e309ca809f1efd154b4d37dc6d9f61037d6c6a81c2dc7a15cb22c8c5dca", size = 49984, upload-time = "2025-10-06T05:37:57.049Z" }, - { url = "https://files.pythonhosted.org/packages/4b/de/ad9d82ca8e5fa8f0c636e64606553c79e2b859ad253030b62a21fe9986f5/frozenlist-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1b9290cf81e95e93fdf90548ce9d3c1211cf574b8e3f4b3b7cb0537cf2227068", size = 50240, upload-time = "2025-10-06T05:37:58.145Z" }, - { url = "https://files.pythonhosted.org/packages/4e/45/3dfb7767c2a67d123650122b62ce13c731b6c745bc14424eea67678b508c/frozenlist-1.8.0-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:59a6a5876ca59d1b63af8cd5e7ffffb024c3dc1e9cf9301b21a2e76286505c95", size = 219472, upload-time = "2025-10-06T05:37:59.239Z" }, - { url = "https://files.pythonhosted.org/packages/0b/bf/5bf23d913a741b960d5c1dac7c1985d8a2a1d015772b2d18ea168b08e7ff/frozenlist-1.8.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6dc4126390929823e2d2d9dc79ab4046ed74680360fc5f38b585c12c66cdf459", size = 221531, upload-time = "2025-10-06T05:38:00.521Z" }, - { url = "https://files.pythonhosted.org/packages/d0/03/27ec393f3b55860859f4b74cdc8c2a4af3dbf3533305e8eacf48a4fd9a54/frozenlist-1.8.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:332db6b2563333c5671fecacd085141b5800cb866be16d5e3eb15a2086476675", size = 219211, upload-time = "2025-10-06T05:38:01.842Z" }, - { url = "https://files.pythonhosted.org/packages/3a/ad/0fd00c404fa73fe9b169429e9a972d5ed807973c40ab6b3cf9365a33d360/frozenlist-1.8.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ff15928d62a0b80bb875655c39bf517938c7d589554cbd2669be42d97c2cb61", size = 231775, upload-time = "2025-10-06T05:38:03.384Z" }, - { url = "https://files.pythonhosted.org/packages/8a/c3/86962566154cb4d2995358bc8331bfc4ea19d07db1a96f64935a1607f2b6/frozenlist-1.8.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7bf6cdf8e07c8151fba6fe85735441240ec7f619f935a5205953d58009aef8c6", size = 236631, upload-time = "2025-10-06T05:38:04.609Z" }, - { url = "https://files.pythonhosted.org/packages/ea/9e/6ffad161dbd83782d2c66dc4d378a9103b31770cb1e67febf43aea42d202/frozenlist-1.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:48e6d3f4ec5c7273dfe83ff27c91083c6c9065af655dc2684d2c200c94308bb5", size = 218632, upload-time = "2025-10-06T05:38:05.917Z" }, - { url = "https://files.pythonhosted.org/packages/58/b2/4677eee46e0a97f9b30735e6ad0bf6aba3e497986066eb68807ac85cf60f/frozenlist-1.8.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:1a7607e17ad33361677adcd1443edf6f5da0ce5e5377b798fba20fae194825f3", size = 235967, upload-time = "2025-10-06T05:38:07.614Z" }, - { url = "https://files.pythonhosted.org/packages/05/f3/86e75f8639c5a93745ca7addbbc9de6af56aebb930d233512b17e46f6493/frozenlist-1.8.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:5a3a935c3a4e89c733303a2d5a7c257ea44af3a56c8202df486b7f5de40f37e1", size = 228799, upload-time = "2025-10-06T05:38:08.845Z" }, - { url = "https://files.pythonhosted.org/packages/30/00/39aad3a7f0d98f5eb1d99a3c311215674ed87061aecee7851974b335c050/frozenlist-1.8.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:940d4a017dbfed9daf46a3b086e1d2167e7012ee297fef9e1c545c4d022f5178", size = 230566, upload-time = "2025-10-06T05:38:10.52Z" }, - { url = "https://files.pythonhosted.org/packages/0d/4d/aa144cac44568d137846ddc4d5210fb5d9719eb1d7ec6fa2728a54b5b94a/frozenlist-1.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b9be22a69a014bc47e78072d0ecae716f5eb56c15238acca0f43d6eb8e4a5bda", size = 217715, upload-time = "2025-10-06T05:38:11.832Z" }, - { url = "https://files.pythonhosted.org/packages/64/4c/8f665921667509d25a0dd72540513bc86b356c95541686f6442a3283019f/frozenlist-1.8.0-cp39-cp39-win32.whl", hash = "sha256:1aa77cb5697069af47472e39612976ed05343ff2e84a3dcf15437b232cbfd087", size = 39933, upload-time = "2025-10-06T05:38:13.061Z" }, - { url = "https://files.pythonhosted.org/packages/79/bd/bcc926f87027fad5e59926ff12d136e1082a115025d33c032d1cd69ab377/frozenlist-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:7398c222d1d405e796970320036b1b563892b65809d9e5261487bb2c7f7b5c6a", size = 44121, upload-time = "2025-10-06T05:38:14.572Z" }, - { url = "https://files.pythonhosted.org/packages/4c/07/9c2e4eb7584af4b705237b971b89a4155a8e57599c4483a131a39256a9a0/frozenlist-1.8.0-cp39-cp39-win_arm64.whl", hash = "sha256:b4f3b365f31c6cd4af24545ca0a244a53688cad8834e32f56831c4923b50a103", size = 40312, upload-time = "2025-10-06T05:38:15.699Z" }, { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] @@ -1499,50 +908,23 @@ name = "geomet" version = "1.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "click", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "click" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2a/8c/dde022aa6747b114f6b14a7392871275dea8867e2bd26cddb80cc6d66620/geomet-1.1.0.tar.gz", hash = "sha256:51e92231a0ef6aaa63ac20c443377ba78a303fd2ecd179dc3567de79f3c11605", size = 28732, upload-time = "2023-11-14T15:43:36.764Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/af/90/3bc780df088d439714af8295196a4332a26559ae66fd99865e36f92efa9e/geomet-1.1.0-py3-none-any.whl", hash = "sha256:4372fe4e286a34acc6f2e9308284850bd8c4aa5bc12065e2abbd4995900db12f", size = 31522, upload-time = "2023-11-14T15:43:35.305Z" }, ] -[[package]] -name = "gremlinpython" -version = "3.7.6" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.9.12' and python_full_version < '3.10'", - "python_full_version > '3.9' and python_full_version < '3.9.12'", - "python_full_version <= '3.9'", -] -dependencies = [ - { name = "aenum", marker = "python_full_version < '3.10'" }, - { name = "aiohttp", version = "3.13.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "async-timeout", marker = "python_full_version < '3.10'" }, - { name = "isodate", marker = "python_full_version < '3.10'" }, - { name = "nest-asyncio", marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e4/67/a9f9b8380d9db4667f04a5e38c1725a337baf6d114a4b608b5faad45c632/gremlinpython-3.7.6.tar.gz", hash = "sha256:a13df1a47c493b8c4d5c9b79b6db5295080ee51b4ebf4987232a120b1b749b3c", size = 52704, upload-time = "2026-04-06T23:12:05.343Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/5d/a2a3016f7c902ab361a01df58949350a94622cb4985ca8304724866948f6/gremlinpython-3.7.6-py3-none-any.whl", hash = "sha256:14fdf6003ebed1b6463c321df67b1e9f4a4893b4e38c9d181cf26ded49a389d7", size = 73962, upload-time = "2026-04-06T23:12:03.803Z" }, -] - [[package]] name = "gremlinpython" version = "3.8.1" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] dependencies = [ - { name = "aenum", marker = "python_full_version >= '3.10'" }, - { name = "aiohttp", version = "3.14.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "async-timeout", marker = "python_full_version >= '3.10'" }, - { name = "isodate", marker = "python_full_version >= '3.10'" }, - { name = "nest-asyncio", marker = "python_full_version >= '3.10'" }, + { name = "aenum" }, + { name = "aiohttp" }, + { name = "async-timeout" }, + { name = "isodate" }, + { name = "nest-asyncio" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5d/56/8867126fc3383ba2aea606e54079353d15568508cf7c60255f727780be8c/gremlinpython-3.8.1.tar.gz", hash = "sha256:23ad0ed694d63ef57611a04d3c648cc6bf05e86678959b6b6d921e83c48fd1f8", size = 53968, upload-time = "2026-04-07T00:22:20.144Z" } wheels = [ @@ -1558,29 +940,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] -[[package]] -name = "iniconfig" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.9.12' and python_full_version < '3.10'", - "python_full_version > '3.9' and python_full_version < '3.9.12'", - "python_full_version <= '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, -] - [[package]] name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, @@ -1604,40 +967,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, ] -[[package]] -name = "jsonschema" -version = "4.25.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.9.12' and python_full_version < '3.10'", - "python_full_version > '3.9' and python_full_version < '3.9.12'", - "python_full_version <= '3.9'", -] -dependencies = [ - { name = "attrs", marker = "python_full_version < '3.10'" }, - { name = "jsonschema-specifications", marker = "python_full_version < '3.10'" }, - { name = "referencing", version = "0.36.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "rpds-py", version = "0.27.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, -] - [[package]] name = "jsonschema" version = "4.26.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] dependencies = [ - { name = "attrs", marker = "python_full_version >= '3.10'" }, - { name = "jsonschema-specifications", marker = "python_full_version >= '3.10'" }, - { name = "referencing", version = "0.37.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } @@ -1650,8 +988,7 @@ name = "jsonschema-specifications" version = "2025.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "referencing", version = "0.36.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "referencing", version = "0.37.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "referencing" }, ] sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } wheels = [ @@ -1663,9 +1000,6 @@ name = "kerberos" version = "1.3.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/39/cd/f98699a6e806b9d974ea1d3376b91f09edcb90415adbf31e3b56ee99ba64/kerberos-1.3.1.tar.gz", hash = "sha256:cdd046142a4e0060f96a00eb13d82a5d9ebc0f2d7934393ed559bac773460a2c", size = 19126, upload-time = "2021-01-09T06:43:46.862Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/9a/d10386fa7da4588e61fdafdbac2953576f7de6f693d112c74f09a9749fb6/kerberos-1.3.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2002b3b1541fc51e2c081ee7048f55e5d9ca63dd09f0d7b951c263920db3a0bb", size = 20248, upload-time = "2021-01-09T06:43:45.915Z" }, -] [[package]] name = "lz4" @@ -1721,14 +1055,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/da/137ddeea14c2cb86864838277b2607d09f8253f152156a07f84e11768a28/lz4-4.4.5-cp314-cp314-win32.whl", hash = "sha256:bd85d118316b53ed73956435bee1997bd06cc66dd2fa74073e3b1322bd520a67", size = 90139, upload-time = "2025-11-03T13:02:24.301Z" }, { url = "https://files.pythonhosted.org/packages/18/2c/8332080fd293f8337779a440b3a143f85e374311705d243439a3349b81ad/lz4-4.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:92159782a4502858a21e0079d77cdcaade23e8a5d252ddf46b0652604300d7be", size = 101497, upload-time = "2025-11-03T13:02:25.187Z" }, { url = "https://files.pythonhosted.org/packages/ca/28/2635a8141c9a4f4bc23f5135a92bbcf48d928d8ca094088c962df1879d64/lz4-4.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:d994b87abaa7a88ceb7a37c90f547b8284ff9da694e6afcfaa8568d739faf3f7", size = 93812, upload-time = "2025-11-03T13:02:26.133Z" }, - { url = "https://files.pythonhosted.org/packages/da/34/508f2ee73c126e4de53a3b8523ad14d666aeb00a6795425315f770dbf2f4/lz4-4.4.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f6538aaaedd091d6e5abdaa19b99e6e82697d67518f114721b5248709b639fad", size = 207384, upload-time = "2025-11-03T13:02:27.043Z" }, - { url = "https://files.pythonhosted.org/packages/64/84/da7fda86dcc7b6d40d45dd28201fc136adfc390815126db41411bf1e5205/lz4-4.4.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:13254bd78fef50105872989a2dc3418ff09aefc7d0765528adc21646a7288294", size = 207137, upload-time = "2025-11-03T13:02:28.021Z" }, - { url = "https://files.pythonhosted.org/packages/01/95/fb9c5bffed0f985eab70daf2087a94ad55cbbf83024175f39ff663f48b22/lz4-4.4.5-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e64e61f29cf95afb43549063d8433b46352baf0c8a70aa45e2585618fcf59d86", size = 1290508, upload-time = "2025-11-03T13:02:29.485Z" }, - { url = "https://files.pythonhosted.org/packages/57/6e/6a39b5ca9b9538cc9d61248c431065ad76cc0f10b40cb07d60b5bdde7750/lz4-4.4.5-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff1b50aeeec64df5603f17984e4b5be6166058dcf8f1e26a3da40d7a0f6ab547", size = 1278102, upload-time = "2025-11-03T13:02:30.878Z" }, - { url = "https://files.pythonhosted.org/packages/73/57/551a7f95825c9721d8bee4ec02d8b139b1a44796e63d09a737ca0d67b6b1/lz4-4.4.5-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1dd4d91d25937c2441b9fc0f4af01704a2d09f30a38c5798bc1d1b5a15ec9581", size = 1366651, upload-time = "2025-11-03T13:02:32.31Z" }, - { url = "https://files.pythonhosted.org/packages/4f/85/daa1ae5695ce40924813257d7f5a8990ba5dd78a9170f912dd85c498f97c/lz4-4.4.5-cp39-cp39-win32.whl", hash = "sha256:d64141085864918392c3159cdad15b102a620a67975c786777874e1e90ef15ce", size = 88165, upload-time = "2025-11-03T13:02:33.413Z" }, - { url = "https://files.pythonhosted.org/packages/df/db/3e84e506fdd5e04c9e8564d30bb08b0f3103dd9a2fb863c86bd46accb99a/lz4-4.4.5-cp39-cp39-win_amd64.whl", hash = "sha256:f32b9e65d70f3684532358255dc053f143835c5f5991e28a5ac4c93ce94b9ea7", size = 99487, upload-time = "2025-11-03T13:02:34.246Z" }, - { url = "https://files.pythonhosted.org/packages/6a/85/40aa9d006fdebc4ae868c86ce2108a9453c2b524284817427de1284b5b00/lz4-4.4.5-cp39-cp39-win_arm64.whl", hash = "sha256:f9b8bde9909a010c75b3aea58ec3910393b758f3c219beed67063693df854db0", size = 91275, upload-time = "2025-11-03T13:02:35.117Z" }, ] [[package]] @@ -1866,24 +1192,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ee/74525ebe3eb5fddcd6735fc03cbea3feeed4122b53bc798ac32d297ac9ae/multidict-6.7.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:65573858d27cdeaca41893185677dc82395159aa28875a8867af66532d413a8f", size = 77107, upload-time = "2026-01-26T02:46:12.608Z" }, - { url = "https://files.pythonhosted.org/packages/f0/9a/ce8744e777a74b3050b1bf56be3eed1053b3457302ea055f1ea437200a23/multidict-6.7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c524c6fb8fc342793708ab111c4dbc90ff9abd568de220432500e47e990c0358", size = 44943, upload-time = "2026-01-26T02:46:14.016Z" }, - { url = "https://files.pythonhosted.org/packages/83/9c/1d2a283d9c6f31e260cb6c2fccadc3edcf6c4c14ee0929cd2af4d2606dd7/multidict-6.7.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:aa23b001d968faef416ff70dc0f1ab045517b9b42a90edd3e9bcdb06479e31d5", size = 44603, upload-time = "2026-01-26T02:46:15.391Z" }, - { url = "https://files.pythonhosted.org/packages/87/9d/3b186201671583d8e8d6d79c07481a5aafd0ba7575e3d8566baec80c1e82/multidict-6.7.1-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6704fa2b7453b2fb121740555fa1ee20cd98c4d011120caf4d2b8d4e7c76eec0", size = 240573, upload-time = "2026-01-26T02:46:16.783Z" }, - { url = "https://files.pythonhosted.org/packages/42/7d/a52f5d4d0754311d1ac78478e34dff88de71259a8585e05ee14e5f877caf/multidict-6.7.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:121a34e5bfa410cdf2c8c49716de160de3b1dbcd86b49656f5681e4543bcd1a8", size = 240106, upload-time = "2026-01-26T02:46:18.432Z" }, - { url = "https://files.pythonhosted.org/packages/84/9f/d80118e6c30ff55b7d171bdc5520aad4b9626e657520b8d7c8ca8c2fad12/multidict-6.7.1-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:026d264228bcd637d4e060844e39cdc60f86c479e463d49075dedc21b18fbbe0", size = 219418, upload-time = "2026-01-26T02:46:20.526Z" }, - { url = "https://files.pythonhosted.org/packages/c7/bd/896e60b3457f194de77c7de64f9acce9f75da0518a5230ce1df534f6747b/multidict-6.7.1-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e697826df7eb63418ee190fd06ce9f1803593bb4b9517d08c60d9b9a7f69d8f", size = 252124, upload-time = "2026-01-26T02:46:22.157Z" }, - { url = "https://files.pythonhosted.org/packages/f4/de/ba6b30447c36a37078d0ba604aa12c1a52887af0c355236ca6e0a9d5286f/multidict-6.7.1-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb08271280173720e9fea9ede98e5231defcbad90f1624bea26f32ec8a956e2f", size = 249402, upload-time = "2026-01-26T02:46:23.718Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b2/50a383c96230e432895a2fd3bcfe1b65785899598259d871d5de6b93180c/multidict-6.7.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6b3228e1d80af737b72925ce5fb4daf5a335e49cd7ab77ed7b9fdfbf58c526e", size = 240346, upload-time = "2026-01-26T02:46:25.393Z" }, - { url = "https://files.pythonhosted.org/packages/89/37/16d391fd8da544b1489306e38a46785fa41dd0f0ef766837ed7d4676dde0/multidict-6.7.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3943debf0fbb57bdde5901695c11094a9a36723e5c03875f87718ee15ca2f4d2", size = 237010, upload-time = "2026-01-26T02:46:27.408Z" }, - { url = "https://files.pythonhosted.org/packages/b0/24/3152ee026eda86d5d3e3685182911e6951af7a016579da931080ce6ac9ad/multidict-6.7.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:98c5787b0a0d9a41d9311eae44c3b76e6753def8d8870ab501320efe75a6a5f8", size = 232018, upload-time = "2026-01-26T02:46:29.941Z" }, - { url = "https://files.pythonhosted.org/packages/9c/1f/48d3c27a72be7fd23a55d8847193c459959bf35a5bb5844530dab00b739b/multidict-6.7.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:08ccb2a6dc72009093ebe7f3f073e5ec5964cba9a706fa94b1a1484039b87941", size = 241498, upload-time = "2026-01-26T02:46:32.052Z" }, - { url = "https://files.pythonhosted.org/packages/1a/45/413643ae2952d0decdf6c1250f86d08a43e143271441e81027e38d598bd7/multidict-6.7.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb351f72c26dc9abe338ca7294661aa22969ad8ffe7ef7d5541d19f368dc854a", size = 247957, upload-time = "2026-01-26T02:46:33.666Z" }, - { url = "https://files.pythonhosted.org/packages/50/f8/f1d0ac23df15e0470776388bdb261506f63af1f81d28bacb5e262d6e12b6/multidict-6.7.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ac1c665bad8b5d762f5f85ebe4d94130c26965f11de70c708c75671297c776de", size = 241651, upload-time = "2026-01-26T02:46:35.7Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c9/1a2a18f383cf129add66b6c36b75c3911a7ba95cf26cb141482de085cc12/multidict-6.7.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fa6609d0364f4f6f58351b4659a1f3e0e898ba2a8c5cac04cb2c7bc556b0bc5", size = 236371, upload-time = "2026-01-26T02:46:37.37Z" }, - { url = "https://files.pythonhosted.org/packages/bb/aa/77d87e3fca31325b87e0eb72d5fe9a7472dcb51391a42df7ac1f3842f6c0/multidict-6.7.1-cp39-cp39-win32.whl", hash = "sha256:6f77ce314a29263e67adadc7e7c1bc699fcb3a305059ab973d038f87caa42ed0", size = 41426, upload-time = "2026-01-26T02:46:39.026Z" }, - { url = "https://files.pythonhosted.org/packages/e3/b3/e8863e6a2da15a9d7e98976ff402e871b7352c76566df6c18d0378e0d9cf/multidict-6.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:f537b55778cd3cbee430abe3131255d3a78202e0f9ea7ffc6ada893a4bcaeea4", size = 46180, upload-time = "2026-01-26T02:46:40.422Z" }, - { url = "https://files.pythonhosted.org/packages/93/d3/dd4fa951ad5b5fa216bf30054d705683d13405eea7459833d78f31b74c9c/multidict-6.7.1-cp39-cp39-win_arm64.whl", hash = "sha256:749aa54f578f2e5f439538706a475aa844bfa8ef75854b1401e6e528e4937cf9", size = 43231, upload-time = "2026-01-26T02:46:41.945Z" }, { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] @@ -1896,69 +1204,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, ] -[[package]] -name = "numpy" -version = "2.0.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.9.12' and python_full_version < '3.10'", - "python_full_version > '3.9' and python_full_version < '3.9.12'", - "python_full_version <= '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/a9/75/10dd1f8116a8b796cb2c737b674e02d02e80454bda953fa7e65d8c12b016/numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78", size = 18902015, upload-time = "2024-08-26T20:19:40.945Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/21/91/3495b3237510f79f5d81f2508f9f13fea78ebfdf07538fc7444badda173d/numpy-2.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:51129a29dbe56f9ca83438b706e2e69a39892b5eda6cedcb6b0c9fdc9b0d3ece", size = 21165245, upload-time = "2024-08-26T20:04:14.625Z" }, - { url = "https://files.pythonhosted.org/packages/05/33/26178c7d437a87082d11019292dce6d3fe6f0e9026b7b2309cbf3e489b1d/numpy-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f15975dfec0cf2239224d80e32c3170b1d168335eaedee69da84fbe9f1f9cd04", size = 13738540, upload-time = "2024-08-26T20:04:36.784Z" }, - { url = "https://files.pythonhosted.org/packages/ec/31/cc46e13bf07644efc7a4bf68df2df5fb2a1a88d0cd0da9ddc84dc0033e51/numpy-2.0.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:8c5713284ce4e282544c68d1c3b2c7161d38c256d2eefc93c1d683cf47683e66", size = 5300623, upload-time = "2024-08-26T20:04:46.491Z" }, - { url = "https://files.pythonhosted.org/packages/6e/16/7bfcebf27bb4f9d7ec67332ffebee4d1bf085c84246552d52dbb548600e7/numpy-2.0.2-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:becfae3ddd30736fe1889a37f1f580e245ba79a5855bff5f2a29cb3ccc22dd7b", size = 6901774, upload-time = "2024-08-26T20:04:58.173Z" }, - { url = "https://files.pythonhosted.org/packages/f9/a3/561c531c0e8bf082c5bef509d00d56f82e0ea7e1e3e3a7fc8fa78742a6e5/numpy-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2da5960c3cf0df7eafefd806d4e612c5e19358de82cb3c343631188991566ccd", size = 13907081, upload-time = "2024-08-26T20:05:19.098Z" }, - { url = "https://files.pythonhosted.org/packages/fa/66/f7177ab331876200ac7563a580140643d1179c8b4b6a6b0fc9838de2a9b8/numpy-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:496f71341824ed9f3d2fd36cf3ac57ae2e0165c143b55c3a035ee219413f3318", size = 19523451, upload-time = "2024-08-26T20:05:47.479Z" }, - { url = "https://files.pythonhosted.org/packages/25/7f/0b209498009ad6453e4efc2c65bcdf0ae08a182b2b7877d7ab38a92dc542/numpy-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a61ec659f68ae254e4d237816e33171497e978140353c0c2038d46e63282d0c8", size = 19927572, upload-time = "2024-08-26T20:06:17.137Z" }, - { url = "https://files.pythonhosted.org/packages/3e/df/2619393b1e1b565cd2d4c4403bdd979621e2c4dea1f8532754b2598ed63b/numpy-2.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d731a1c6116ba289c1e9ee714b08a8ff882944d4ad631fd411106a30f083c326", size = 14400722, upload-time = "2024-08-26T20:06:39.16Z" }, - { url = "https://files.pythonhosted.org/packages/22/ad/77e921b9f256d5da36424ffb711ae79ca3f451ff8489eeca544d0701d74a/numpy-2.0.2-cp310-cp310-win32.whl", hash = "sha256:984d96121c9f9616cd33fbd0618b7f08e0cfc9600a7ee1d6fd9b239186d19d97", size = 6472170, upload-time = "2024-08-26T20:06:50.361Z" }, - { url = "https://files.pythonhosted.org/packages/10/05/3442317535028bc29cf0c0dd4c191a4481e8376e9f0db6bcf29703cadae6/numpy-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:c7b0be4ef08607dd04da4092faee0b86607f111d5ae68036f16cc787e250a131", size = 15905558, upload-time = "2024-08-26T20:07:13.881Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cf/034500fb83041aa0286e0fb16e7c76e5c8b67c0711bb6e9e9737a717d5fe/numpy-2.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:49ca4decb342d66018b01932139c0961a8f9ddc7589611158cb3c27cbcf76448", size = 21169137, upload-time = "2024-08-26T20:07:45.345Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d9/32de45561811a4b87fbdee23b5797394e3d1504b4a7cf40c10199848893e/numpy-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:11a76c372d1d37437857280aa142086476136a8c0f373b2e648ab2c8f18fb195", size = 13703552, upload-time = "2024-08-26T20:08:06.666Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ca/2f384720020c7b244d22508cb7ab23d95f179fcfff33c31a6eeba8d6c512/numpy-2.0.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:807ec44583fd708a21d4a11d94aedf2f4f3c3719035c76a2bbe1fe8e217bdc57", size = 5298957, upload-time = "2024-08-26T20:08:15.83Z" }, - { url = "https://files.pythonhosted.org/packages/0e/78/a3e4f9fb6aa4e6fdca0c5428e8ba039408514388cf62d89651aade838269/numpy-2.0.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8cafab480740e22f8d833acefed5cc87ce276f4ece12fdaa2e8903db2f82897a", size = 6905573, upload-time = "2024-08-26T20:08:27.185Z" }, - { url = "https://files.pythonhosted.org/packages/a0/72/cfc3a1beb2caf4efc9d0b38a15fe34025230da27e1c08cc2eb9bfb1c7231/numpy-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a15f476a45e6e5a3a79d8a14e62161d27ad897381fecfa4a09ed5322f2085669", size = 13914330, upload-time = "2024-08-26T20:08:48.058Z" }, - { url = "https://files.pythonhosted.org/packages/ba/a8/c17acf65a931ce551fee11b72e8de63bf7e8a6f0e21add4c937c83563538/numpy-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13e689d772146140a252c3a28501da66dfecd77490b498b168b501835041f951", size = 19534895, upload-time = "2024-08-26T20:09:16.536Z" }, - { url = "https://files.pythonhosted.org/packages/ba/86/8767f3d54f6ae0165749f84648da9dcc8cd78ab65d415494962c86fac80f/numpy-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9ea91dfb7c3d1c56a0e55657c0afb38cf1eeae4544c208dc465c3c9f3a7c09f9", size = 19937253, upload-time = "2024-08-26T20:09:46.263Z" }, - { url = "https://files.pythonhosted.org/packages/df/87/f76450e6e1c14e5bb1eae6836478b1028e096fd02e85c1c37674606ab752/numpy-2.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c1c9307701fec8f3f7a1e6711f9089c06e6284b3afbbcd259f7791282d660a15", size = 14414074, upload-time = "2024-08-26T20:10:08.483Z" }, - { url = "https://files.pythonhosted.org/packages/5c/ca/0f0f328e1e59f73754f06e1adfb909de43726d4f24c6a3f8805f34f2b0fa/numpy-2.0.2-cp311-cp311-win32.whl", hash = "sha256:a392a68bd329eafac5817e5aefeb39038c48b671afd242710b451e76090e81f4", size = 6470640, upload-time = "2024-08-26T20:10:19.732Z" }, - { url = "https://files.pythonhosted.org/packages/eb/57/3a3f14d3a759dcf9bf6e9eda905794726b758819df4663f217d658a58695/numpy-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:286cd40ce2b7d652a6f22efdfc6d1edf879440e53e76a75955bc0c826c7e64dc", size = 15910230, upload-time = "2024-08-26T20:10:43.413Z" }, - { url = "https://files.pythonhosted.org/packages/45/40/2e117be60ec50d98fa08c2f8c48e09b3edea93cfcabd5a9ff6925d54b1c2/numpy-2.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:df55d490dea7934f330006d0f81e8551ba6010a5bf035a249ef61a94f21c500b", size = 20895803, upload-time = "2024-08-26T20:11:13.916Z" }, - { url = "https://files.pythonhosted.org/packages/46/92/1b8b8dee833f53cef3e0a3f69b2374467789e0bb7399689582314df02651/numpy-2.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8df823f570d9adf0978347d1f926b2a867d5608f434a7cff7f7908c6570dcf5e", size = 13471835, upload-time = "2024-08-26T20:11:34.779Z" }, - { url = "https://files.pythonhosted.org/packages/7f/19/e2793bde475f1edaea6945be141aef6c8b4c669b90c90a300a8954d08f0a/numpy-2.0.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9a92ae5c14811e390f3767053ff54eaee3bf84576d99a2456391401323f4ec2c", size = 5038499, upload-time = "2024-08-26T20:11:43.902Z" }, - { url = "https://files.pythonhosted.org/packages/e3/ff/ddf6dac2ff0dd50a7327bcdba45cb0264d0e96bb44d33324853f781a8f3c/numpy-2.0.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:a842d573724391493a97a62ebbb8e731f8a5dcc5d285dfc99141ca15a3302d0c", size = 6633497, upload-time = "2024-08-26T20:11:55.09Z" }, - { url = "https://files.pythonhosted.org/packages/72/21/67f36eac8e2d2cd652a2e69595a54128297cdcb1ff3931cfc87838874bd4/numpy-2.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05e238064fc0610c840d1cf6a13bf63d7e391717d247f1bf0318172e759e692", size = 13621158, upload-time = "2024-08-26T20:12:14.95Z" }, - { url = "https://files.pythonhosted.org/packages/39/68/e9f1126d757653496dbc096cb429014347a36b228f5a991dae2c6b6cfd40/numpy-2.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0123ffdaa88fa4ab64835dcbde75dcdf89c453c922f18dced6e27c90d1d0ec5a", size = 19236173, upload-time = "2024-08-26T20:12:44.049Z" }, - { url = "https://files.pythonhosted.org/packages/d1/e9/1f5333281e4ebf483ba1c888b1d61ba7e78d7e910fdd8e6499667041cc35/numpy-2.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:96a55f64139912d61de9137f11bf39a55ec8faec288c75a54f93dfd39f7eb40c", size = 19634174, upload-time = "2024-08-26T20:13:13.634Z" }, - { url = "https://files.pythonhosted.org/packages/71/af/a469674070c8d8408384e3012e064299f7a2de540738a8e414dcfd639996/numpy-2.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec9852fb39354b5a45a80bdab5ac02dd02b15f44b3804e9f00c556bf24b4bded", size = 14099701, upload-time = "2024-08-26T20:13:34.851Z" }, - { url = "https://files.pythonhosted.org/packages/d0/3d/08ea9f239d0e0e939b6ca52ad403c84a2bce1bde301a8eb4888c1c1543f1/numpy-2.0.2-cp312-cp312-win32.whl", hash = "sha256:671bec6496f83202ed2d3c8fdc486a8fc86942f2e69ff0e986140339a63bcbe5", size = 6174313, upload-time = "2024-08-26T20:13:45.653Z" }, - { url = "https://files.pythonhosted.org/packages/b2/b5/4ac39baebf1fdb2e72585c8352c56d063b6126be9fc95bd2bb5ef5770c20/numpy-2.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:cfd41e13fdc257aa5778496b8caa5e856dc4896d4ccf01841daee1d96465467a", size = 15606179, upload-time = "2024-08-26T20:14:08.786Z" }, - { url = "https://files.pythonhosted.org/packages/43/c1/41c8f6df3162b0c6ffd4437d729115704bd43363de0090c7f913cfbc2d89/numpy-2.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9059e10581ce4093f735ed23f3b9d283b9d517ff46009ddd485f1747eb22653c", size = 21169942, upload-time = "2024-08-26T20:14:40.108Z" }, - { url = "https://files.pythonhosted.org/packages/39/bc/fd298f308dcd232b56a4031fd6ddf11c43f9917fbc937e53762f7b5a3bb1/numpy-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:423e89b23490805d2a5a96fe40ec507407b8ee786d66f7328be214f9679df6dd", size = 13711512, upload-time = "2024-08-26T20:15:00.985Z" }, - { url = "https://files.pythonhosted.org/packages/96/ff/06d1aa3eeb1c614eda245c1ba4fb88c483bee6520d361641331872ac4b82/numpy-2.0.2-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:2b2955fa6f11907cf7a70dab0d0755159bca87755e831e47932367fc8f2f2d0b", size = 5306976, upload-time = "2024-08-26T20:15:10.876Z" }, - { url = "https://files.pythonhosted.org/packages/2d/98/121996dcfb10a6087a05e54453e28e58694a7db62c5a5a29cee14c6e047b/numpy-2.0.2-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:97032a27bd9d8988b9a97a8c4d2c9f2c15a81f61e2f21404d7e8ef00cb5be729", size = 6906494, upload-time = "2024-08-26T20:15:22.055Z" }, - { url = "https://files.pythonhosted.org/packages/15/31/9dffc70da6b9bbf7968f6551967fc21156207366272c2a40b4ed6008dc9b/numpy-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e795a8be3ddbac43274f18588329c72939870a16cae810c2b73461c40718ab1", size = 13912596, upload-time = "2024-08-26T20:15:42.452Z" }, - { url = "https://files.pythonhosted.org/packages/b9/14/78635daab4b07c0930c919d451b8bf8c164774e6a3413aed04a6d95758ce/numpy-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b258c385842546006213344c50655ff1555a9338e2e5e02a0756dc3e803dd", size = 19526099, upload-time = "2024-08-26T20:16:11.048Z" }, - { url = "https://files.pythonhosted.org/packages/26/4c/0eeca4614003077f68bfe7aac8b7496f04221865b3a5e7cb230c9d055afd/numpy-2.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fec9451a7789926bcf7c2b8d187292c9f93ea30284802a0ab3f5be8ab36865d", size = 19932823, upload-time = "2024-08-26T20:16:40.171Z" }, - { url = "https://files.pythonhosted.org/packages/f1/46/ea25b98b13dccaebddf1a803f8c748680d972e00507cd9bc6dcdb5aa2ac1/numpy-2.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9189427407d88ff25ecf8f12469d4d39d35bee1db5d39fc5c168c6f088a6956d", size = 14404424, upload-time = "2024-08-26T20:17:02.604Z" }, - { url = "https://files.pythonhosted.org/packages/c8/a6/177dd88d95ecf07e722d21008b1b40e681a929eb9e329684d449c36586b2/numpy-2.0.2-cp39-cp39-win32.whl", hash = "sha256:905d16e0c60200656500c95b6b8dca5d109e23cb24abc701d41c02d74c6b3afa", size = 6476809, upload-time = "2024-08-26T20:17:13.553Z" }, - { url = "https://files.pythonhosted.org/packages/ea/2b/7fc9f4e7ae5b507c1a3a21f0f15ed03e794c1242ea8a242ac158beb56034/numpy-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:a3f4ab0caa7f053f6797fcd4e1e25caee367db3112ef2b6ef82d749530768c73", size = 15911314, upload-time = "2024-08-26T20:17:36.72Z" }, - { url = "https://files.pythonhosted.org/packages/8f/3b/df5a870ac6a3be3a86856ce195ef42eec7ae50d2a202be1f5a4b3b340e14/numpy-2.0.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7f0a0c6f12e07fa94133c8a67404322845220c06a9e80e85999afe727f7438b8", size = 21025288, upload-time = "2024-08-26T20:18:07.732Z" }, - { url = "https://files.pythonhosted.org/packages/2c/97/51af92f18d6f6f2d9ad8b482a99fb74e142d71372da5d834b3a2747a446e/numpy-2.0.2-pp39-pypy39_pp73-macosx_14_0_x86_64.whl", hash = "sha256:312950fdd060354350ed123c0e25a71327d3711584beaef30cdaa93320c392d4", size = 6762793, upload-time = "2024-08-26T20:18:19.125Z" }, - { url = "https://files.pythonhosted.org/packages/12/46/de1fbd0c1b5ccaa7f9a005b66761533e2f6a3e560096682683a223631fe9/numpy-2.0.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26df23238872200f63518dd2aa984cfca675d82469535dc7162dc2ee52d9dd5c", size = 19334885, upload-time = "2024-08-26T20:18:47.237Z" }, - { url = "https://files.pythonhosted.org/packages/cc/dc/d330a6faefd92b446ec0f0dfea4c3207bb1fef3c4771d19cf4543efd2c78/numpy-2.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a46288ec55ebbd58947d31d72be2c63cbf839f0a63b49cb755022310792a3385", size = 15828784, upload-time = "2024-08-26T20:19:11.19Z" }, -] - [[package]] name = "numpy" version = "2.2.6" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version == '3.10.*'", + "python_full_version < '3.11'", ] sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } wheels = [ @@ -2181,149 +1432,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] -[[package]] -name = "propcache" -version = "0.4.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.9.12' and python_full_version < '3.10'", - "python_full_version > '3.9' and python_full_version < '3.9.12'", - "python_full_version <= '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/0e/934b541323035566a9af292dba85a195f7b78179114f2c6ebb24551118a9/propcache-0.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c2d1fa3201efaf55d730400d945b5b3ab6e672e100ba0f9a409d950ab25d7db", size = 79534, upload-time = "2025-10-08T19:46:02.083Z" }, - { url = "https://files.pythonhosted.org/packages/a1/6b/db0d03d96726d995dc7171286c6ba9d8d14251f37433890f88368951a44e/propcache-0.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1eb2994229cc8ce7fe9b3db88f5465f5fd8651672840b2e426b88cdb1a30aac8", size = 45526, upload-time = "2025-10-08T19:46:03.884Z" }, - { url = "https://files.pythonhosted.org/packages/e4/c3/82728404aea669e1600f304f2609cde9e665c18df5a11cdd57ed73c1dceb/propcache-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:66c1f011f45a3b33d7bcb22daed4b29c0c9e2224758b6be00686731e1b46f925", size = 47263, upload-time = "2025-10-08T19:46:05.405Z" }, - { url = "https://files.pythonhosted.org/packages/df/1b/39313ddad2bf9187a1432654c38249bab4562ef535ef07f5eb6eb04d0b1b/propcache-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9a52009f2adffe195d0b605c25ec929d26b36ef986ba85244891dee3b294df21", size = 201012, upload-time = "2025-10-08T19:46:07.165Z" }, - { url = "https://files.pythonhosted.org/packages/5b/01/f1d0b57d136f294a142acf97f4ed58c8e5b974c21e543000968357115011/propcache-0.4.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5d4e2366a9c7b837555cf02fb9be2e3167d333aff716332ef1b7c3a142ec40c5", size = 209491, upload-time = "2025-10-08T19:46:08.909Z" }, - { url = "https://files.pythonhosted.org/packages/a1/c8/038d909c61c5bb039070b3fb02ad5cccdb1dde0d714792e251cdb17c9c05/propcache-0.4.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9d2b6caef873b4f09e26ea7e33d65f42b944837563a47a94719cc3544319a0db", size = 215319, upload-time = "2025-10-08T19:46:10.7Z" }, - { url = "https://files.pythonhosted.org/packages/08/57/8c87e93142b2c1fa2408e45695205a7ba05fb5db458c0bf5c06ba0e09ea6/propcache-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b16ec437a8c8a965ecf95739448dd938b5c7f56e67ea009f4300d8df05f32b7", size = 196856, upload-time = "2025-10-08T19:46:12.003Z" }, - { url = "https://files.pythonhosted.org/packages/42/df/5615fec76aa561987a534759b3686008a288e73107faa49a8ae5795a9f7a/propcache-0.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:296f4c8ed03ca7476813fe666c9ea97869a8d7aec972618671b33a38a5182ef4", size = 193241, upload-time = "2025-10-08T19:46:13.495Z" }, - { url = "https://files.pythonhosted.org/packages/d5/21/62949eb3a7a54afe8327011c90aca7e03547787a88fb8bd9726806482fea/propcache-0.4.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1f0978529a418ebd1f49dad413a2b68af33f85d5c5ca5c6ca2a3bed375a7ac60", size = 190552, upload-time = "2025-10-08T19:46:14.938Z" }, - { url = "https://files.pythonhosted.org/packages/30/ee/ab4d727dd70806e5b4de96a798ae7ac6e4d42516f030ee60522474b6b332/propcache-0.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fd138803047fb4c062b1c1dd95462f5209456bfab55c734458f15d11da288f8f", size = 200113, upload-time = "2025-10-08T19:46:16.695Z" }, - { url = "https://files.pythonhosted.org/packages/8a/0b/38b46208e6711b016aa8966a3ac793eee0d05c7159d8342aa27fc0bc365e/propcache-0.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8c9b3cbe4584636d72ff556d9036e0c9317fa27b3ac1f0f558e7e84d1c9c5900", size = 200778, upload-time = "2025-10-08T19:46:18.023Z" }, - { url = "https://files.pythonhosted.org/packages/cf/81/5abec54355ed344476bee711e9f04815d4b00a311ab0535599204eecc257/propcache-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f93243fdc5657247533273ac4f86ae106cc6445a0efacb9a1bfe982fcfefd90c", size = 193047, upload-time = "2025-10-08T19:46:19.449Z" }, - { url = "https://files.pythonhosted.org/packages/ec/b6/1f237c04e32063cb034acd5f6ef34ef3a394f75502e72703545631ab1ef6/propcache-0.4.1-cp310-cp310-win32.whl", hash = "sha256:a0ee98db9c5f80785b266eb805016e36058ac72c51a064040f2bc43b61101cdb", size = 38093, upload-time = "2025-10-08T19:46:20.643Z" }, - { url = "https://files.pythonhosted.org/packages/a6/67/354aac4e0603a15f76439caf0427781bcd6797f370377f75a642133bc954/propcache-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:1cdb7988c4e5ac7f6d175a28a9aa0c94cb6f2ebe52756a3c0cda98d2809a9e37", size = 41638, upload-time = "2025-10-08T19:46:21.935Z" }, - { url = "https://files.pythonhosted.org/packages/e0/e1/74e55b9fd1a4c209ff1a9a824bf6c8b3d1fc5a1ac3eabe23462637466785/propcache-0.4.1-cp310-cp310-win_arm64.whl", hash = "sha256:d82ad62b19645419fe79dd63b3f9253e15b30e955c0170e5cebc350c1844e581", size = 38229, upload-time = "2025-10-08T19:46:23.368Z" }, - { url = "https://files.pythonhosted.org/packages/8c/d4/4e2c9aaf7ac2242b9358f98dccd8f90f2605402f5afeff6c578682c2c491/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", size = 80208, upload-time = "2025-10-08T19:46:24.597Z" }, - { url = "https://files.pythonhosted.org/packages/c2/21/d7b68e911f9c8e18e4ae43bdbc1e1e9bbd971f8866eb81608947b6f585ff/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", size = 45777, upload-time = "2025-10-08T19:46:25.733Z" }, - { url = "https://files.pythonhosted.org/packages/d3/1d/11605e99ac8ea9435651ee71ab4cb4bf03f0949586246476a25aadfec54a/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", size = 47647, upload-time = "2025-10-08T19:46:27.304Z" }, - { url = "https://files.pythonhosted.org/packages/58/1a/3c62c127a8466c9c843bccb503d40a273e5cc69838805f322e2826509e0d/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566", size = 214929, upload-time = "2025-10-08T19:46:28.62Z" }, - { url = "https://files.pythonhosted.org/packages/56/b9/8fa98f850960b367c4b8fe0592e7fc341daa7a9462e925228f10a60cf74f/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165", size = 221778, upload-time = "2025-10-08T19:46:30.358Z" }, - { url = "https://files.pythonhosted.org/packages/46/a6/0ab4f660eb59649d14b3d3d65c439421cf2f87fe5dd68591cbe3c1e78a89/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc", size = 228144, upload-time = "2025-10-08T19:46:32.607Z" }, - { url = "https://files.pythonhosted.org/packages/52/6a/57f43e054fb3d3a56ac9fc532bc684fc6169a26c75c353e65425b3e56eef/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48", size = 210030, upload-time = "2025-10-08T19:46:33.969Z" }, - { url = "https://files.pythonhosted.org/packages/40/e2/27e6feebb5f6b8408fa29f5efbb765cd54c153ac77314d27e457a3e993b7/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570", size = 208252, upload-time = "2025-10-08T19:46:35.309Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f8/91c27b22ccda1dbc7967f921c42825564fa5336a01ecd72eb78a9f4f53c2/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85", size = 202064, upload-time = "2025-10-08T19:46:36.993Z" }, - { url = "https://files.pythonhosted.org/packages/f2/26/7f00bd6bd1adba5aafe5f4a66390f243acab58eab24ff1a08bebb2ef9d40/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e", size = 212429, upload-time = "2025-10-08T19:46:38.398Z" }, - { url = "https://files.pythonhosted.org/packages/84/89/fd108ba7815c1117ddca79c228f3f8a15fc82a73bca8b142eb5de13b2785/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757", size = 216727, upload-time = "2025-10-08T19:46:39.732Z" }, - { url = "https://files.pythonhosted.org/packages/79/37/3ec3f7e3173e73f1d600495d8b545b53802cbf35506e5732dd8578db3724/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f", size = 205097, upload-time = "2025-10-08T19:46:41.025Z" }, - { url = "https://files.pythonhosted.org/packages/61/b0/b2631c19793f869d35f47d5a3a56fb19e9160d3c119f15ac7344fc3ccae7/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1", size = 38084, upload-time = "2025-10-08T19:46:42.693Z" }, - { url = "https://files.pythonhosted.org/packages/f4/78/6cce448e2098e9f3bfc91bb877f06aa24b6ccace872e39c53b2f707c4648/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6", size = 41637, upload-time = "2025-10-08T19:46:43.778Z" }, - { url = "https://files.pythonhosted.org/packages/9c/e9/754f180cccd7f51a39913782c74717c581b9cc8177ad0e949f4d51812383/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239", size = 38064, upload-time = "2025-10-08T19:46:44.872Z" }, - { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, - { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, - { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, - { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, - { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, - { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, - { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, - { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, - { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, - { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, - { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, - { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, - { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, - { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, - { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, - { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, - { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, - { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, - { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, - { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, - { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, - { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, - { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, - { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, - { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, - { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, - { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, - { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, - { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, - { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, - { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, - { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, - { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, - { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, - { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, - { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, - { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, - { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, - { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, - { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, - { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, - { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, - { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, - { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, - { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, - { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, - { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, - { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, - { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, - { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, - { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, - { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, - { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, - { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, - { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, - { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, - { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, - { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, - { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, - { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, - { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, - { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, - { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, - { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, - { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, - { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, - { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, - { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, - { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, - { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, - { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, - { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, - { url = "https://files.pythonhosted.org/packages/9b/01/0ebaec9003f5d619a7475165961f8e3083cf8644d704b60395df3601632d/propcache-0.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3d233076ccf9e450c8b3bc6720af226b898ef5d051a2d145f7d765e6e9f9bcff", size = 80277, upload-time = "2025-10-08T19:48:36.647Z" }, - { url = "https://files.pythonhosted.org/packages/34/58/04af97ac586b4ef6b9026c3fd36ee7798b737a832f5d3440a4280dcebd3a/propcache-0.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:357f5bb5c377a82e105e44bd3d52ba22b616f7b9773714bff93573988ef0a5fb", size = 45865, upload-time = "2025-10-08T19:48:37.859Z" }, - { url = "https://files.pythonhosted.org/packages/7c/19/b65d98ae21384518b291d9939e24a8aeac4fdb5101b732576f8f7540e834/propcache-0.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cbc3b6dfc728105b2a57c06791eb07a94229202ea75c59db644d7d496b698cac", size = 47636, upload-time = "2025-10-08T19:48:39.038Z" }, - { url = "https://files.pythonhosted.org/packages/b3/0f/317048c6d91c356c7154dca5af019e6effeb7ee15fa6a6db327cc19e12b4/propcache-0.4.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:182b51b421f0501952d938dc0b0eb45246a5b5153c50d42b495ad5fb7517c888", size = 201126, upload-time = "2025-10-08T19:48:40.774Z" }, - { url = "https://files.pythonhosted.org/packages/71/69/0b2a7a5a6ee83292b4b997dbd80549d8ce7d40b6397c1646c0d9495f5a85/propcache-0.4.1-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4b536b39c5199b96fc6245eb5fb796c497381d3942f169e44e8e392b29c9ebcc", size = 209837, upload-time = "2025-10-08T19:48:42.167Z" }, - { url = "https://files.pythonhosted.org/packages/a5/92/c699ac495a6698df6e497fc2de27af4b6ace10d8e76528357ce153722e45/propcache-0.4.1-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:db65d2af507bbfbdcedb254a11149f894169d90488dd3e7190f7cdcb2d6cd57a", size = 215578, upload-time = "2025-10-08T19:48:43.56Z" }, - { url = "https://files.pythonhosted.org/packages/b3/ee/14de81c5eb02c0ee4f500b4e39c4e1bd0677c06e72379e6ab18923c773fc/propcache-0.4.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd2dbc472da1f772a4dae4fa24be938a6c544671a912e30529984dd80400cd88", size = 197187, upload-time = "2025-10-08T19:48:45.309Z" }, - { url = "https://files.pythonhosted.org/packages/1d/94/48dce9aaa6d8dd5a0859bad75158ec522546d4ac23f8e2f05fac469477dd/propcache-0.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:daede9cd44e0f8bdd9e6cc9a607fc81feb80fae7a5fc6cecaff0e0bb32e42d00", size = 193478, upload-time = "2025-10-08T19:48:47.743Z" }, - { url = "https://files.pythonhosted.org/packages/60/b5/0516b563e801e1ace212afde869a0596a0d7115eec0b12d296d75633fb29/propcache-0.4.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:71b749281b816793678ae7f3d0d84bd36e694953822eaad408d682efc5ca18e0", size = 190650, upload-time = "2025-10-08T19:48:49.373Z" }, - { url = "https://files.pythonhosted.org/packages/24/89/e0f7d4a5978cd56f8cd67735f74052f257dc471ec901694e430f0d1572fe/propcache-0.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:0002004213ee1f36cfb3f9a42b5066100c44276b9b72b4e1504cddd3d692e86e", size = 200251, upload-time = "2025-10-08T19:48:51.4Z" }, - { url = "https://files.pythonhosted.org/packages/06/7d/a1fac863d473876ed4406c914f2e14aa82d2f10dd207c9e16fc383cc5a24/propcache-0.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:fe49d0a85038f36ba9e3ffafa1103e61170b28e95b16622e11be0a0ea07c6781", size = 200919, upload-time = "2025-10-08T19:48:53.227Z" }, - { url = "https://files.pythonhosted.org/packages/c3/4e/f86a256ff24944cf5743e4e6c6994e3526f6acfcfb55e21694c2424f758c/propcache-0.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:99d43339c83aaf4d32bda60928231848eee470c6bda8d02599cc4cebe872d183", size = 193211, upload-time = "2025-10-08T19:48:55.027Z" }, - { url = "https://files.pythonhosted.org/packages/6e/3f/3fbad5f4356b068f1b047d300a6ff2c66614d7030f078cd50be3fec04228/propcache-0.4.1-cp39-cp39-win32.whl", hash = "sha256:a129e76735bc792794d5177069691c3217898b9f5cee2b2661471e52ffe13f19", size = 38314, upload-time = "2025-10-08T19:48:56.792Z" }, - { url = "https://files.pythonhosted.org/packages/a4/45/d78d136c3a3d215677abb886785aae744da2c3005bcb99e58640c56529b1/propcache-0.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:948dab269721ae9a87fd16c514a0a2c2a1bdb23a9a61b969b0f9d9ee2968546f", size = 41912, upload-time = "2025-10-08T19:48:57.995Z" }, - { url = "https://files.pythonhosted.org/packages/fc/2a/b0632941f25139f4e58450b307242951f7c2717a5704977c6d5323a800af/propcache-0.4.1-cp39-cp39-win_arm64.whl", hash = "sha256:5fd37c406dd6dc85aa743e214cef35dc54bbdd1419baac4f6ae5e5b1a2976938", size = 38450, upload-time = "2025-10-08T19:48:59.349Z" }, - { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, -] - [[package]] name = "propcache" version = "0.5.2" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5b/56/030b7b4719d53085722893e0009dffb9236aa10bca1b12121bdc5626ef16/propcache-0.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a81be28596d6559f6131ef33e10200de6e17643b3c74ce03f9eb103be6ae8b", size = 93417, upload-time = "2026-05-08T20:59:15.597Z" }, @@ -2482,29 +1594,10 @@ version = "0.6.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/83/b7/a0d688f86c869073cc28c0640899394a1cf68a6d87ee78a09565e9037da6/pure-sasl-0.6.2.tar.gz", hash = "sha256:53c1355f5da95e2b85b2cc9a6af435518edc20c81193faa0eea65fdc835138f4", size = 11617, upload-time = "2019-10-14T21:43:57.13Z" } -[[package]] -name = "pycparser" -version = "2.23" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.9.12' and python_full_version < '3.10'", - "python_full_version > '3.9' and python_full_version < '3.9.12'", - "python_full_version <= '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, -] - [[package]] name = "pycparser" version = "3.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, @@ -2521,21 +1614,20 @@ wheels = [ [[package]] name = "pytest" -version = "8.4.2" +version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] [[package]] @@ -2624,269 +1716,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, - { url = "https://files.pythonhosted.org/packages/9f/62/67fc8e68a75f738c9200422bf65693fb79a4cd0dc5b23310e5202e978090/pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", size = 184450, upload-time = "2025-09-25T21:33:00.618Z" }, - { url = "https://files.pythonhosted.org/packages/ae/92/861f152ce87c452b11b9d0977952259aa7df792d71c1053365cc7b09cc08/pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", size = 174319, upload-time = "2025-09-25T21:33:02.086Z" }, - { url = "https://files.pythonhosted.org/packages/d0/cd/f0cfc8c74f8a030017a2b9c771b7f47e5dd702c3e28e5b2071374bda2948/pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", size = 737631, upload-time = "2025-09-25T21:33:03.25Z" }, - { url = "https://files.pythonhosted.org/packages/ef/b2/18f2bd28cd2055a79a46c9b0895c0b3d987ce40ee471cecf58a1a0199805/pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", size = 836795, upload-time = "2025-09-25T21:33:05.014Z" }, - { url = "https://files.pythonhosted.org/packages/73/b9/793686b2d54b531203c160ef12bec60228a0109c79bae6c1277961026770/pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", size = 750767, upload-time = "2025-09-25T21:33:06.398Z" }, - { url = "https://files.pythonhosted.org/packages/a9/86/a137b39a611def2ed78b0e66ce2fe13ee701a07c07aebe55c340ed2a050e/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", size = 727982, upload-time = "2025-09-25T21:33:08.708Z" }, - { url = "https://files.pythonhosted.org/packages/dd/62/71c27c94f457cf4418ef8ccc71735324c549f7e3ea9d34aba50874563561/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", size = 755677, upload-time = "2025-09-25T21:33:09.876Z" }, - { url = "https://files.pythonhosted.org/packages/29/3d/6f5e0d58bd924fb0d06c3a6bad00effbdae2de5adb5cda5648006ffbd8d3/pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", size = 142592, upload-time = "2025-09-25T21:33:10.983Z" }, - { url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777, upload-time = "2025-09-25T21:33:15.55Z" }, -] - -[[package]] -name = "referencing" -version = "0.36.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.9.12' and python_full_version < '3.10'", - "python_full_version > '3.9' and python_full_version < '3.9.12'", - "python_full_version <= '3.9'", -] -dependencies = [ - { name = "attrs", marker = "python_full_version < '3.10'" }, - { name = "rpds-py", version = "0.27.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "typing-extensions", marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" }, ] [[package]] name = "referencing" version = "0.37.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] dependencies = [ - { name = "attrs", marker = "python_full_version >= '3.10'" }, - { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "attrs" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, ] -[[package]] -name = "requests" -version = "2.32.5" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.9.12' and python_full_version < '3.10'", - "python_full_version > '3.9' and python_full_version < '3.9.12'", - "python_full_version <= '3.9'", -] -dependencies = [ - { name = "certifi", marker = "python_full_version < '3.10'" }, - { name = "charset-normalizer", marker = "python_full_version < '3.10'" }, - { name = "idna", marker = "python_full_version < '3.10'" }, - { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, -] - [[package]] name = "requests" version = "2.34.2" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] dependencies = [ - { name = "certifi", marker = "python_full_version >= '3.10'" }, - { name = "charset-normalizer", marker = "python_full_version >= '3.10'" }, - { name = "idna", marker = "python_full_version >= '3.10'" }, - { name = "urllib3", version = "2.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] -[[package]] -name = "rpds-py" -version = "0.27.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.9.12' and python_full_version < '3.10'", - "python_full_version > '3.9' and python_full_version < '3.9.12'", - "python_full_version <= '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/e9/dd/2c0cbe774744272b0ae725f44032c77bdcab6e8bcf544bffa3b6e70c8dba/rpds_py-0.27.1.tar.gz", hash = "sha256:26a1c73171d10b7acccbded82bf6a586ab8203601e565badc74bbbf8bc5a10f8", size = 27479, upload-time = "2025-08-27T12:16:36.024Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/ed/3aef893e2dd30e77e35d20d4ddb45ca459db59cead748cad9796ad479411/rpds_py-0.27.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:68afeec26d42ab3b47e541b272166a0b4400313946871cba3ed3a4fc0cab1cef", size = 371606, upload-time = "2025-08-27T12:12:25.189Z" }, - { url = "https://files.pythonhosted.org/packages/6d/82/9818b443e5d3eb4c83c3994561387f116aae9833b35c484474769c4a8faf/rpds_py-0.27.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:74e5b2f7bb6fa38b1b10546d27acbacf2a022a8b5543efb06cfebc72a59c85be", size = 353452, upload-time = "2025-08-27T12:12:27.433Z" }, - { url = "https://files.pythonhosted.org/packages/99/c7/d2a110ffaaa397fc6793a83c7bd3545d9ab22658b7cdff05a24a4535cc45/rpds_py-0.27.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9024de74731df54546fab0bfbcdb49fae19159ecaecfc8f37c18d2c7e2c0bd61", size = 381519, upload-time = "2025-08-27T12:12:28.719Z" }, - { url = "https://files.pythonhosted.org/packages/5a/bc/e89581d1f9d1be7d0247eaef602566869fdc0d084008ba139e27e775366c/rpds_py-0.27.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:31d3ebadefcd73b73928ed0b2fd696f7fefda8629229f81929ac9c1854d0cffb", size = 394424, upload-time = "2025-08-27T12:12:30.207Z" }, - { url = "https://files.pythonhosted.org/packages/ac/2e/36a6861f797530e74bb6ed53495f8741f1ef95939eed01d761e73d559067/rpds_py-0.27.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2e7f8f169d775dd9092a1743768d771f1d1300453ddfe6325ae3ab5332b4657", size = 523467, upload-time = "2025-08-27T12:12:31.808Z" }, - { url = "https://files.pythonhosted.org/packages/c4/59/c1bc2be32564fa499f988f0a5c6505c2f4746ef96e58e4d7de5cf923d77e/rpds_py-0.27.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d905d16f77eb6ab2e324e09bfa277b4c8e5e6b8a78a3e7ff8f3cdf773b4c013", size = 402660, upload-time = "2025-08-27T12:12:33.444Z" }, - { url = "https://files.pythonhosted.org/packages/0a/ec/ef8bf895f0628dd0a59e54d81caed6891663cb9c54a0f4bb7da918cb88cf/rpds_py-0.27.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50c946f048209e6362e22576baea09193809f87687a95a8db24e5fbdb307b93a", size = 384062, upload-time = "2025-08-27T12:12:34.857Z" }, - { url = "https://files.pythonhosted.org/packages/69/f7/f47ff154be8d9a5e691c083a920bba89cef88d5247c241c10b9898f595a1/rpds_py-0.27.1-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:3deab27804d65cd8289eb814c2c0e807c4b9d9916c9225e363cb0cf875eb67c1", size = 401289, upload-time = "2025-08-27T12:12:36.085Z" }, - { url = "https://files.pythonhosted.org/packages/3b/d9/ca410363efd0615814ae579f6829cafb39225cd63e5ea5ed1404cb345293/rpds_py-0.27.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8b61097f7488de4be8244c89915da8ed212832ccf1e7c7753a25a394bf9b1f10", size = 417718, upload-time = "2025-08-27T12:12:37.401Z" }, - { url = "https://files.pythonhosted.org/packages/e3/a0/8cb5c2ff38340f221cc067cc093d1270e10658ba4e8d263df923daa18e86/rpds_py-0.27.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8a3f29aba6e2d7d90528d3c792555a93497fe6538aa65eb675b44505be747808", size = 558333, upload-time = "2025-08-27T12:12:38.672Z" }, - { url = "https://files.pythonhosted.org/packages/6f/8c/1b0de79177c5d5103843774ce12b84caa7164dfc6cd66378768d37db11bf/rpds_py-0.27.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dd6cd0485b7d347304067153a6dc1d73f7d4fd995a396ef32a24d24b8ac63ac8", size = 589127, upload-time = "2025-08-27T12:12:41.48Z" }, - { url = "https://files.pythonhosted.org/packages/c8/5e/26abb098d5e01266b0f3a2488d299d19ccc26849735d9d2b95c39397e945/rpds_py-0.27.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6f4461bf931108c9fa226ffb0e257c1b18dc2d44cd72b125bec50ee0ab1248a9", size = 554899, upload-time = "2025-08-27T12:12:42.925Z" }, - { url = "https://files.pythonhosted.org/packages/de/41/905cc90ced13550db017f8f20c6d8e8470066c5738ba480d7ba63e3d136b/rpds_py-0.27.1-cp310-cp310-win32.whl", hash = "sha256:ee5422d7fb21f6a00c1901bf6559c49fee13a5159d0288320737bbf6585bd3e4", size = 217450, upload-time = "2025-08-27T12:12:44.813Z" }, - { url = "https://files.pythonhosted.org/packages/75/3d/6bef47b0e253616ccdf67c283e25f2d16e18ccddd38f92af81d5a3420206/rpds_py-0.27.1-cp310-cp310-win_amd64.whl", hash = "sha256:3e039aabf6d5f83c745d5f9a0a381d031e9ed871967c0a5c38d201aca41f3ba1", size = 228447, upload-time = "2025-08-27T12:12:46.204Z" }, - { url = "https://files.pythonhosted.org/packages/b5/c1/7907329fbef97cbd49db6f7303893bd1dd5a4a3eae415839ffdfb0762cae/rpds_py-0.27.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:be898f271f851f68b318872ce6ebebbc62f303b654e43bf72683dbdc25b7c881", size = 371063, upload-time = "2025-08-27T12:12:47.856Z" }, - { url = "https://files.pythonhosted.org/packages/11/94/2aab4bc86228bcf7c48760990273653a4900de89c7537ffe1b0d6097ed39/rpds_py-0.27.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:62ac3d4e3e07b58ee0ddecd71d6ce3b1637de2d373501412df395a0ec5f9beb5", size = 353210, upload-time = "2025-08-27T12:12:49.187Z" }, - { url = "https://files.pythonhosted.org/packages/3a/57/f5eb3ecf434342f4f1a46009530e93fd201a0b5b83379034ebdb1d7c1a58/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4708c5c0ceb2d034f9991623631d3d23cb16e65c83736ea020cdbe28d57c0a0e", size = 381636, upload-time = "2025-08-27T12:12:50.492Z" }, - { url = "https://files.pythonhosted.org/packages/ae/f4/ef95c5945e2ceb5119571b184dd5a1cc4b8541bbdf67461998cfeac9cb1e/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:abfa1171a9952d2e0002aba2ad3780820b00cc3d9c98c6630f2e93271501f66c", size = 394341, upload-time = "2025-08-27T12:12:52.024Z" }, - { url = "https://files.pythonhosted.org/packages/5a/7e/4bd610754bf492d398b61725eb9598ddd5eb86b07d7d9483dbcd810e20bc/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b507d19f817ebaca79574b16eb2ae412e5c0835542c93fe9983f1e432aca195", size = 523428, upload-time = "2025-08-27T12:12:53.779Z" }, - { url = "https://files.pythonhosted.org/packages/9f/e5/059b9f65a8c9149361a8b75094864ab83b94718344db511fd6117936ed2a/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:168b025f8fd8d8d10957405f3fdcef3dc20f5982d398f90851f4abc58c566c52", size = 402923, upload-time = "2025-08-27T12:12:55.15Z" }, - { url = "https://files.pythonhosted.org/packages/f5/48/64cabb7daced2968dd08e8a1b7988bf358d7bd5bcd5dc89a652f4668543c/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb56c6210ef77caa58e16e8c17d35c63fe3f5b60fd9ba9d424470c3400bcf9ed", size = 384094, upload-time = "2025-08-27T12:12:57.194Z" }, - { url = "https://files.pythonhosted.org/packages/ae/e1/dc9094d6ff566bff87add8a510c89b9e158ad2ecd97ee26e677da29a9e1b/rpds_py-0.27.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:d252f2d8ca0195faa707f8eb9368955760880b2b42a8ee16d382bf5dd807f89a", size = 401093, upload-time = "2025-08-27T12:12:58.985Z" }, - { url = "https://files.pythonhosted.org/packages/37/8e/ac8577e3ecdd5593e283d46907d7011618994e1d7ab992711ae0f78b9937/rpds_py-0.27.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6e5e54da1e74b91dbc7996b56640f79b195d5925c2b78efaa8c5d53e1d88edde", size = 417969, upload-time = "2025-08-27T12:13:00.367Z" }, - { url = "https://files.pythonhosted.org/packages/66/6d/87507430a8f74a93556fe55c6485ba9c259949a853ce407b1e23fea5ba31/rpds_py-0.27.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ffce0481cc6e95e5b3f0a47ee17ffbd234399e6d532f394c8dce320c3b089c21", size = 558302, upload-time = "2025-08-27T12:13:01.737Z" }, - { url = "https://files.pythonhosted.org/packages/3a/bb/1db4781ce1dda3eecc735e3152659a27b90a02ca62bfeea17aee45cc0fbc/rpds_py-0.27.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a205fdfe55c90c2cd8e540ca9ceba65cbe6629b443bc05db1f590a3db8189ff9", size = 589259, upload-time = "2025-08-27T12:13:03.127Z" }, - { url = "https://files.pythonhosted.org/packages/7b/0e/ae1c8943d11a814d01b482e1f8da903f88047a962dff9bbdadf3bd6e6fd1/rpds_py-0.27.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:689fb5200a749db0415b092972e8eba85847c23885c8543a8b0f5c009b1a5948", size = 554983, upload-time = "2025-08-27T12:13:04.516Z" }, - { url = "https://files.pythonhosted.org/packages/b2/d5/0b2a55415931db4f112bdab072443ff76131b5ac4f4dc98d10d2d357eb03/rpds_py-0.27.1-cp311-cp311-win32.whl", hash = "sha256:3182af66048c00a075010bc7f4860f33913528a4b6fc09094a6e7598e462fe39", size = 217154, upload-time = "2025-08-27T12:13:06.278Z" }, - { url = "https://files.pythonhosted.org/packages/24/75/3b7ffe0d50dc86a6a964af0d1cc3a4a2cdf437cb7b099a4747bbb96d1819/rpds_py-0.27.1-cp311-cp311-win_amd64.whl", hash = "sha256:b4938466c6b257b2f5c4ff98acd8128ec36b5059e5c8f8372d79316b1c36bb15", size = 228627, upload-time = "2025-08-27T12:13:07.625Z" }, - { url = "https://files.pythonhosted.org/packages/8d/3f/4fd04c32abc02c710f09a72a30c9a55ea3cc154ef8099078fd50a0596f8e/rpds_py-0.27.1-cp311-cp311-win_arm64.whl", hash = "sha256:2f57af9b4d0793e53266ee4325535a31ba48e2f875da81a9177c9926dfa60746", size = 220998, upload-time = "2025-08-27T12:13:08.972Z" }, - { url = "https://files.pythonhosted.org/packages/bd/fe/38de28dee5df58b8198c743fe2bea0c785c6d40941b9950bac4cdb71a014/rpds_py-0.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ae2775c1973e3c30316892737b91f9283f9908e3cc7625b9331271eaaed7dc90", size = 361887, upload-time = "2025-08-27T12:13:10.233Z" }, - { url = "https://files.pythonhosted.org/packages/7c/9a/4b6c7eedc7dd90986bf0fab6ea2a091ec11c01b15f8ba0a14d3f80450468/rpds_py-0.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2643400120f55c8a96f7c9d858f7be0c88d383cd4653ae2cf0d0c88f668073e5", size = 345795, upload-time = "2025-08-27T12:13:11.65Z" }, - { url = "https://files.pythonhosted.org/packages/6f/0e/e650e1b81922847a09cca820237b0edee69416a01268b7754d506ade11ad/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16323f674c089b0360674a4abd28d5042947d54ba620f72514d69be4ff64845e", size = 385121, upload-time = "2025-08-27T12:13:13.008Z" }, - { url = "https://files.pythonhosted.org/packages/1b/ea/b306067a712988e2bff00dcc7c8f31d26c29b6d5931b461aa4b60a013e33/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a1f4814b65eacac94a00fc9a526e3fdafd78e439469644032032d0d63de4881", size = 398976, upload-time = "2025-08-27T12:13:14.368Z" }, - { url = "https://files.pythonhosted.org/packages/2c/0a/26dc43c8840cb8fe239fe12dbc8d8de40f2365e838f3d395835dde72f0e5/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ba32c16b064267b22f1850a34051121d423b6f7338a12b9459550eb2096e7ec", size = 525953, upload-time = "2025-08-27T12:13:15.774Z" }, - { url = "https://files.pythonhosted.org/packages/22/14/c85e8127b573aaf3a0cbd7fbb8c9c99e735a4a02180c84da2a463b766e9e/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5c20f33fd10485b80f65e800bbe5f6785af510b9f4056c5a3c612ebc83ba6cb", size = 407915, upload-time = "2025-08-27T12:13:17.379Z" }, - { url = "https://files.pythonhosted.org/packages/ed/7b/8f4fee9ba1fb5ec856eb22d725a4efa3deb47f769597c809e03578b0f9d9/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:466bfe65bd932da36ff279ddd92de56b042f2266d752719beb97b08526268ec5", size = 386883, upload-time = "2025-08-27T12:13:18.704Z" }, - { url = "https://files.pythonhosted.org/packages/86/47/28fa6d60f8b74fcdceba81b272f8d9836ac0340570f68f5df6b41838547b/rpds_py-0.27.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:41e532bbdcb57c92ba3be62c42e9f096431b4cf478da9bc3bc6ce5c38ab7ba7a", size = 405699, upload-time = "2025-08-27T12:13:20.089Z" }, - { url = "https://files.pythonhosted.org/packages/d0/fd/c5987b5e054548df56953a21fe2ebed51fc1ec7c8f24fd41c067b68c4a0a/rpds_py-0.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f149826d742b406579466283769a8ea448eed82a789af0ed17b0cd5770433444", size = 423713, upload-time = "2025-08-27T12:13:21.436Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ba/3c4978b54a73ed19a7d74531be37a8bcc542d917c770e14d372b8daea186/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80c60cfb5310677bd67cb1e85a1e8eb52e12529545441b43e6f14d90b878775a", size = 562324, upload-time = "2025-08-27T12:13:22.789Z" }, - { url = "https://files.pythonhosted.org/packages/b5/6c/6943a91768fec16db09a42b08644b960cff540c66aab89b74be6d4a144ba/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7ee6521b9baf06085f62ba9c7a3e5becffbc32480d2f1b351559c001c38ce4c1", size = 593646, upload-time = "2025-08-27T12:13:24.122Z" }, - { url = "https://files.pythonhosted.org/packages/11/73/9d7a8f4be5f4396f011a6bb7a19fe26303a0dac9064462f5651ced2f572f/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a512c8263249a9d68cac08b05dd59d2b3f2061d99b322813cbcc14c3c7421998", size = 558137, upload-time = "2025-08-27T12:13:25.557Z" }, - { url = "https://files.pythonhosted.org/packages/6e/96/6772cbfa0e2485bcceef8071de7821f81aeac8bb45fbfd5542a3e8108165/rpds_py-0.27.1-cp312-cp312-win32.whl", hash = "sha256:819064fa048ba01b6dadc5116f3ac48610435ac9a0058bbde98e569f9e785c39", size = 221343, upload-time = "2025-08-27T12:13:26.967Z" }, - { url = "https://files.pythonhosted.org/packages/67/b6/c82f0faa9af1c6a64669f73a17ee0eeef25aff30bb9a1c318509efe45d84/rpds_py-0.27.1-cp312-cp312-win_amd64.whl", hash = "sha256:d9199717881f13c32c4046a15f024971a3b78ad4ea029e8da6b86e5aa9cf4594", size = 232497, upload-time = "2025-08-27T12:13:28.326Z" }, - { url = "https://files.pythonhosted.org/packages/e1/96/2817b44bd2ed11aebacc9251da03689d56109b9aba5e311297b6902136e2/rpds_py-0.27.1-cp312-cp312-win_arm64.whl", hash = "sha256:33aa65b97826a0e885ef6e278fbd934e98cdcfed80b63946025f01e2f5b29502", size = 222790, upload-time = "2025-08-27T12:13:29.71Z" }, - { url = "https://files.pythonhosted.org/packages/cc/77/610aeee8d41e39080c7e14afa5387138e3c9fa9756ab893d09d99e7d8e98/rpds_py-0.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e4b9fcfbc021633863a37e92571d6f91851fa656f0180246e84cbd8b3f6b329b", size = 361741, upload-time = "2025-08-27T12:13:31.039Z" }, - { url = "https://files.pythonhosted.org/packages/3a/fc/c43765f201c6a1c60be2043cbdb664013def52460a4c7adace89d6682bf4/rpds_py-0.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1441811a96eadca93c517d08df75de45e5ffe68aa3089924f963c782c4b898cf", size = 345574, upload-time = "2025-08-27T12:13:32.902Z" }, - { url = "https://files.pythonhosted.org/packages/20/42/ee2b2ca114294cd9847d0ef9c26d2b0851b2e7e00bf14cc4c0b581df0fc3/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55266dafa22e672f5a4f65019015f90336ed31c6383bd53f5e7826d21a0e0b83", size = 385051, upload-time = "2025-08-27T12:13:34.228Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e8/1e430fe311e4799e02e2d1af7c765f024e95e17d651612425b226705f910/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d78827d7ac08627ea2c8e02c9e5b41180ea5ea1f747e9db0915e3adf36b62dcf", size = 398395, upload-time = "2025-08-27T12:13:36.132Z" }, - { url = "https://files.pythonhosted.org/packages/82/95/9dc227d441ff2670651c27a739acb2535ccaf8b351a88d78c088965e5996/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae92443798a40a92dc5f0b01d8a7c93adde0c4dc965310a29ae7c64d72b9fad2", size = 524334, upload-time = "2025-08-27T12:13:37.562Z" }, - { url = "https://files.pythonhosted.org/packages/87/01/a670c232f401d9ad461d9a332aa4080cd3cb1d1df18213dbd0d2a6a7ab51/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c46c9dd2403b66a2a3b9720ec4b74d4ab49d4fabf9f03dfdce2d42af913fe8d0", size = 407691, upload-time = "2025-08-27T12:13:38.94Z" }, - { url = "https://files.pythonhosted.org/packages/03/36/0a14aebbaa26fe7fab4780c76f2239e76cc95a0090bdb25e31d95c492fcd/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2efe4eb1d01b7f5f1939f4ef30ecea6c6b3521eec451fb93191bf84b2a522418", size = 386868, upload-time = "2025-08-27T12:13:40.192Z" }, - { url = "https://files.pythonhosted.org/packages/3b/03/8c897fb8b5347ff6c1cc31239b9611c5bf79d78c984430887a353e1409a1/rpds_py-0.27.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:15d3b4d83582d10c601f481eca29c3f138d44c92187d197aff663a269197c02d", size = 405469, upload-time = "2025-08-27T12:13:41.496Z" }, - { url = "https://files.pythonhosted.org/packages/da/07/88c60edc2df74850d496d78a1fdcdc7b54360a7f610a4d50008309d41b94/rpds_py-0.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4ed2e16abbc982a169d30d1a420274a709949e2cbdef119fe2ec9d870b42f274", size = 422125, upload-time = "2025-08-27T12:13:42.802Z" }, - { url = "https://files.pythonhosted.org/packages/6b/86/5f4c707603e41b05f191a749984f390dabcbc467cf833769b47bf14ba04f/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a75f305c9b013289121ec0f1181931975df78738cdf650093e6b86d74aa7d8dd", size = 562341, upload-time = "2025-08-27T12:13:44.472Z" }, - { url = "https://files.pythonhosted.org/packages/b2/92/3c0cb2492094e3cd9baf9e49bbb7befeceb584ea0c1a8b5939dca4da12e5/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:67ce7620704745881a3d4b0ada80ab4d99df390838839921f99e63c474f82cf2", size = 592511, upload-time = "2025-08-27T12:13:45.898Z" }, - { url = "https://files.pythonhosted.org/packages/10/bb/82e64fbb0047c46a168faa28d0d45a7851cd0582f850b966811d30f67ad8/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d992ac10eb86d9b6f369647b6a3f412fc0075cfd5d799530e84d335e440a002", size = 557736, upload-time = "2025-08-27T12:13:47.408Z" }, - { url = "https://files.pythonhosted.org/packages/00/95/3c863973d409210da7fb41958172c6b7dbe7fc34e04d3cc1f10bb85e979f/rpds_py-0.27.1-cp313-cp313-win32.whl", hash = "sha256:4f75e4bd8ab8db624e02c8e2fc4063021b58becdbe6df793a8111d9343aec1e3", size = 221462, upload-time = "2025-08-27T12:13:48.742Z" }, - { url = "https://files.pythonhosted.org/packages/ce/2c/5867b14a81dc217b56d95a9f2a40fdbc56a1ab0181b80132beeecbd4b2d6/rpds_py-0.27.1-cp313-cp313-win_amd64.whl", hash = "sha256:f9025faafc62ed0b75a53e541895ca272815bec18abe2249ff6501c8f2e12b83", size = 232034, upload-time = "2025-08-27T12:13:50.11Z" }, - { url = "https://files.pythonhosted.org/packages/c7/78/3958f3f018c01923823f1e47f1cc338e398814b92d83cd278364446fac66/rpds_py-0.27.1-cp313-cp313-win_arm64.whl", hash = "sha256:ed10dc32829e7d222b7d3b93136d25a406ba9788f6a7ebf6809092da1f4d279d", size = 222392, upload-time = "2025-08-27T12:13:52.587Z" }, - { url = "https://files.pythonhosted.org/packages/01/76/1cdf1f91aed5c3a7bf2eba1f1c4e4d6f57832d73003919a20118870ea659/rpds_py-0.27.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:92022bbbad0d4426e616815b16bc4127f83c9a74940e1ccf3cfe0b387aba0228", size = 358355, upload-time = "2025-08-27T12:13:54.012Z" }, - { url = "https://files.pythonhosted.org/packages/c3/6f/bf142541229374287604caf3bb2a4ae17f0a580798fd72d3b009b532db4e/rpds_py-0.27.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:47162fdab9407ec3f160805ac3e154df042e577dd53341745fc7fb3f625e6d92", size = 342138, upload-time = "2025-08-27T12:13:55.791Z" }, - { url = "https://files.pythonhosted.org/packages/1a/77/355b1c041d6be40886c44ff5e798b4e2769e497b790f0f7fd1e78d17e9a8/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb89bec23fddc489e5d78b550a7b773557c9ab58b7946154a10a6f7a214a48b2", size = 380247, upload-time = "2025-08-27T12:13:57.683Z" }, - { url = "https://files.pythonhosted.org/packages/d6/a4/d9cef5c3946ea271ce2243c51481971cd6e34f21925af2783dd17b26e815/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e48af21883ded2b3e9eb48cb7880ad8598b31ab752ff3be6457001d78f416723", size = 390699, upload-time = "2025-08-27T12:13:59.137Z" }, - { url = "https://files.pythonhosted.org/packages/3a/06/005106a7b8c6c1a7e91b73169e49870f4af5256119d34a361ae5240a0c1d/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f5b7bd8e219ed50299e58551a410b64daafb5017d54bbe822e003856f06a802", size = 521852, upload-time = "2025-08-27T12:14:00.583Z" }, - { url = "https://files.pythonhosted.org/packages/e5/3e/50fb1dac0948e17a02eb05c24510a8fe12d5ce8561c6b7b7d1339ab7ab9c/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08f1e20bccf73b08d12d804d6e1c22ca5530e71659e6673bce31a6bb71c1e73f", size = 402582, upload-time = "2025-08-27T12:14:02.034Z" }, - { url = "https://files.pythonhosted.org/packages/cb/b0/f4e224090dc5b0ec15f31a02d746ab24101dd430847c4d99123798661bfc/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dc5dceeaefcc96dc192e3a80bbe1d6c410c469e97bdd47494a7d930987f18b2", size = 384126, upload-time = "2025-08-27T12:14:03.437Z" }, - { url = "https://files.pythonhosted.org/packages/54/77/ac339d5f82b6afff1df8f0fe0d2145cc827992cb5f8eeb90fc9f31ef7a63/rpds_py-0.27.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:d76f9cc8665acdc0c9177043746775aa7babbf479b5520b78ae4002d889f5c21", size = 399486, upload-time = "2025-08-27T12:14:05.443Z" }, - { url = "https://files.pythonhosted.org/packages/d6/29/3e1c255eee6ac358c056a57d6d6869baa00a62fa32eea5ee0632039c50a3/rpds_py-0.27.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:134fae0e36022edad8290a6661edf40c023562964efea0cc0ec7f5d392d2aaef", size = 414832, upload-time = "2025-08-27T12:14:06.902Z" }, - { url = "https://files.pythonhosted.org/packages/3f/db/6d498b844342deb3fa1d030598db93937a9964fcf5cb4da4feb5f17be34b/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb11a4f1b2b63337cfd3b4d110af778a59aae51c81d195768e353d8b52f88081", size = 557249, upload-time = "2025-08-27T12:14:08.37Z" }, - { url = "https://files.pythonhosted.org/packages/60/f3/690dd38e2310b6f68858a331399b4d6dbb9132c3e8ef8b4333b96caf403d/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:13e608ac9f50a0ed4faec0e90ece76ae33b34c0e8656e3dceb9a7db994c692cd", size = 587356, upload-time = "2025-08-27T12:14:10.034Z" }, - { url = "https://files.pythonhosted.org/packages/86/e3/84507781cccd0145f35b1dc32c72675200c5ce8d5b30f813e49424ef68fc/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dd2135527aa40f061350c3f8f89da2644de26cd73e4de458e79606384f4f68e7", size = 555300, upload-time = "2025-08-27T12:14:11.783Z" }, - { url = "https://files.pythonhosted.org/packages/e5/ee/375469849e6b429b3516206b4580a79e9ef3eb12920ddbd4492b56eaacbe/rpds_py-0.27.1-cp313-cp313t-win32.whl", hash = "sha256:3020724ade63fe320a972e2ffd93b5623227e684315adce194941167fee02688", size = 216714, upload-time = "2025-08-27T12:14:13.629Z" }, - { url = "https://files.pythonhosted.org/packages/21/87/3fc94e47c9bd0742660e84706c311a860dcae4374cf4a03c477e23ce605a/rpds_py-0.27.1-cp313-cp313t-win_amd64.whl", hash = "sha256:8ee50c3e41739886606388ba3ab3ee2aae9f35fb23f833091833255a31740797", size = 228943, upload-time = "2025-08-27T12:14:14.937Z" }, - { url = "https://files.pythonhosted.org/packages/70/36/b6e6066520a07cf029d385de869729a895917b411e777ab1cde878100a1d/rpds_py-0.27.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:acb9aafccaae278f449d9c713b64a9e68662e7799dbd5859e2c6b3c67b56d334", size = 362472, upload-time = "2025-08-27T12:14:16.333Z" }, - { url = "https://files.pythonhosted.org/packages/af/07/b4646032e0dcec0df9c73a3bd52f63bc6c5f9cda992f06bd0e73fe3fbebd/rpds_py-0.27.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b7fb801aa7f845ddf601c49630deeeccde7ce10065561d92729bfe81bd21fb33", size = 345676, upload-time = "2025-08-27T12:14:17.764Z" }, - { url = "https://files.pythonhosted.org/packages/b0/16/2f1003ee5d0af4bcb13c0cf894957984c32a6751ed7206db2aee7379a55e/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe0dd05afb46597b9a2e11c351e5e4283c741237e7f617ffb3252780cca9336a", size = 385313, upload-time = "2025-08-27T12:14:19.829Z" }, - { url = "https://files.pythonhosted.org/packages/05/cd/7eb6dd7b232e7f2654d03fa07f1414d7dfc980e82ba71e40a7c46fd95484/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b6dfb0e058adb12d8b1d1b25f686e94ffa65d9995a5157afe99743bf7369d62b", size = 399080, upload-time = "2025-08-27T12:14:21.531Z" }, - { url = "https://files.pythonhosted.org/packages/20/51/5829afd5000ec1cb60f304711f02572d619040aa3ec033d8226817d1e571/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed090ccd235f6fa8bb5861684567f0a83e04f52dfc2e5c05f2e4b1309fcf85e7", size = 523868, upload-time = "2025-08-27T12:14:23.485Z" }, - { url = "https://files.pythonhosted.org/packages/05/2c/30eebca20d5db95720ab4d2faec1b5e4c1025c473f703738c371241476a2/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bf876e79763eecf3e7356f157540d6a093cef395b65514f17a356f62af6cc136", size = 408750, upload-time = "2025-08-27T12:14:24.924Z" }, - { url = "https://files.pythonhosted.org/packages/90/1a/cdb5083f043597c4d4276eae4e4c70c55ab5accec078da8611f24575a367/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:12ed005216a51b1d6e2b02a7bd31885fe317e45897de81d86dcce7d74618ffff", size = 387688, upload-time = "2025-08-27T12:14:27.537Z" }, - { url = "https://files.pythonhosted.org/packages/7c/92/cf786a15320e173f945d205ab31585cc43969743bb1a48b6888f7a2b0a2d/rpds_py-0.27.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:ee4308f409a40e50593c7e3bb8cbe0b4d4c66d1674a316324f0c2f5383b486f9", size = 407225, upload-time = "2025-08-27T12:14:28.981Z" }, - { url = "https://files.pythonhosted.org/packages/33/5c/85ee16df5b65063ef26017bef33096557a4c83fbe56218ac7cd8c235f16d/rpds_py-0.27.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0b08d152555acf1f455154d498ca855618c1378ec810646fcd7c76416ac6dc60", size = 423361, upload-time = "2025-08-27T12:14:30.469Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8e/1c2741307fcabd1a334ecf008e92c4f47bb6f848712cf15c923becfe82bb/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dce51c828941973a5684d458214d3a36fcd28da3e1875d659388f4f9f12cc33e", size = 562493, upload-time = "2025-08-27T12:14:31.987Z" }, - { url = "https://files.pythonhosted.org/packages/04/03/5159321baae9b2222442a70c1f988cbbd66b9be0675dd3936461269be360/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c1476d6f29eb81aa4151c9a31219b03f1f798dc43d8af1250a870735516a1212", size = 592623, upload-time = "2025-08-27T12:14:33.543Z" }, - { url = "https://files.pythonhosted.org/packages/ff/39/c09fd1ad28b85bc1d4554a8710233c9f4cefd03d7717a1b8fbfd171d1167/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3ce0cac322b0d69b63c9cdb895ee1b65805ec9ffad37639f291dd79467bee675", size = 558800, upload-time = "2025-08-27T12:14:35.436Z" }, - { url = "https://files.pythonhosted.org/packages/c5/d6/99228e6bbcf4baa764b18258f519a9035131d91b538d4e0e294313462a98/rpds_py-0.27.1-cp314-cp314-win32.whl", hash = "sha256:dfbfac137d2a3d0725758cd141f878bf4329ba25e34979797c89474a89a8a3a3", size = 221943, upload-time = "2025-08-27T12:14:36.898Z" }, - { url = "https://files.pythonhosted.org/packages/be/07/c802bc6b8e95be83b79bdf23d1aa61d68324cb1006e245d6c58e959e314d/rpds_py-0.27.1-cp314-cp314-win_amd64.whl", hash = "sha256:a6e57b0abfe7cc513450fcf529eb486b6e4d3f8aee83e92eb5f1ef848218d456", size = 233739, upload-time = "2025-08-27T12:14:38.386Z" }, - { url = "https://files.pythonhosted.org/packages/c8/89/3e1b1c16d4c2d547c5717377a8df99aee8099ff050f87c45cb4d5fa70891/rpds_py-0.27.1-cp314-cp314-win_arm64.whl", hash = "sha256:faf8d146f3d476abfee026c4ae3bdd9ca14236ae4e4c310cbd1cf75ba33d24a3", size = 223120, upload-time = "2025-08-27T12:14:39.82Z" }, - { url = "https://files.pythonhosted.org/packages/62/7e/dc7931dc2fa4a6e46b2a4fa744a9fe5c548efd70e0ba74f40b39fa4a8c10/rpds_py-0.27.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:ba81d2b56b6d4911ce735aad0a1d4495e808b8ee4dc58715998741a26874e7c2", size = 358944, upload-time = "2025-08-27T12:14:41.199Z" }, - { url = "https://files.pythonhosted.org/packages/e6/22/4af76ac4e9f336bfb1a5f240d18a33c6b2fcaadb7472ac7680576512b49a/rpds_py-0.27.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:84f7d509870098de0e864cad0102711c1e24e9b1a50ee713b65928adb22269e4", size = 342283, upload-time = "2025-08-27T12:14:42.699Z" }, - { url = "https://files.pythonhosted.org/packages/1c/15/2a7c619b3c2272ea9feb9ade67a45c40b3eeb500d503ad4c28c395dc51b4/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9e960fc78fecd1100539f14132425e1d5fe44ecb9239f8f27f079962021523e", size = 380320, upload-time = "2025-08-27T12:14:44.157Z" }, - { url = "https://files.pythonhosted.org/packages/a2/7d/4c6d243ba4a3057e994bb5bedd01b5c963c12fe38dde707a52acdb3849e7/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62f85b665cedab1a503747617393573995dac4600ff51869d69ad2f39eb5e817", size = 391760, upload-time = "2025-08-27T12:14:45.845Z" }, - { url = "https://files.pythonhosted.org/packages/b4/71/b19401a909b83bcd67f90221330bc1ef11bc486fe4e04c24388d28a618ae/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fed467af29776f6556250c9ed85ea5a4dd121ab56a5f8b206e3e7a4c551e48ec", size = 522476, upload-time = "2025-08-27T12:14:47.364Z" }, - { url = "https://files.pythonhosted.org/packages/e4/44/1a3b9715c0455d2e2f0f6df5ee6d6f5afdc423d0773a8a682ed2b43c566c/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2729615f9d430af0ae6b36cf042cb55c0936408d543fb691e1a9e36648fd35a", size = 403418, upload-time = "2025-08-27T12:14:49.991Z" }, - { url = "https://files.pythonhosted.org/packages/1c/4b/fb6c4f14984eb56673bc868a66536f53417ddb13ed44b391998100a06a96/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b207d881a9aef7ba753d69c123a35d96ca7cb808056998f6b9e8747321f03b8", size = 384771, upload-time = "2025-08-27T12:14:52.159Z" }, - { url = "https://files.pythonhosted.org/packages/c0/56/d5265d2d28b7420d7b4d4d85cad8ef891760f5135102e60d5c970b976e41/rpds_py-0.27.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:639fd5efec029f99b79ae47e5d7e00ad8a773da899b6309f6786ecaf22948c48", size = 400022, upload-time = "2025-08-27T12:14:53.859Z" }, - { url = "https://files.pythonhosted.org/packages/8f/e9/9f5fc70164a569bdd6ed9046486c3568d6926e3a49bdefeeccfb18655875/rpds_py-0.27.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fecc80cb2a90e28af8a9b366edacf33d7a91cbfe4c2c4544ea1246e949cfebeb", size = 416787, upload-time = "2025-08-27T12:14:55.673Z" }, - { url = "https://files.pythonhosted.org/packages/d4/64/56dd03430ba491db943a81dcdef115a985aac5f44f565cd39a00c766d45c/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42a89282d711711d0a62d6f57d81aa43a1368686c45bc1c46b7f079d55692734", size = 557538, upload-time = "2025-08-27T12:14:57.245Z" }, - { url = "https://files.pythonhosted.org/packages/3f/36/92cc885a3129993b1d963a2a42ecf64e6a8e129d2c7cc980dbeba84e55fb/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:cf9931f14223de59551ab9d38ed18d92f14f055a5f78c1d8ad6493f735021bbb", size = 588512, upload-time = "2025-08-27T12:14:58.728Z" }, - { url = "https://files.pythonhosted.org/packages/dd/10/6b283707780a81919f71625351182b4f98932ac89a09023cb61865136244/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f39f58a27cc6e59f432b568ed8429c7e1641324fbe38131de852cd77b2d534b0", size = 555813, upload-time = "2025-08-27T12:15:00.334Z" }, - { url = "https://files.pythonhosted.org/packages/04/2e/30b5ea18c01379da6272a92825dd7e53dc9d15c88a19e97932d35d430ef7/rpds_py-0.27.1-cp314-cp314t-win32.whl", hash = "sha256:d5fa0ee122dc09e23607a28e6d7b150da16c662e66409bbe85230e4c85bb528a", size = 217385, upload-time = "2025-08-27T12:15:01.937Z" }, - { url = "https://files.pythonhosted.org/packages/32/7d/97119da51cb1dd3f2f3c0805f155a3aa4a95fa44fe7d78ae15e69edf4f34/rpds_py-0.27.1-cp314-cp314t-win_amd64.whl", hash = "sha256:6567d2bb951e21232c2f660c24cf3470bb96de56cdcb3f071a83feeaff8a2772", size = 230097, upload-time = "2025-08-27T12:15:03.961Z" }, - { url = "https://files.pythonhosted.org/packages/7f/6c/252e83e1ce7583c81f26d1d884b2074d40a13977e1b6c9c50bbf9a7f1f5a/rpds_py-0.27.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c918c65ec2e42c2a78d19f18c553d77319119bf43aa9e2edf7fb78d624355527", size = 372140, upload-time = "2025-08-27T12:15:05.441Z" }, - { url = "https://files.pythonhosted.org/packages/9d/71/949c195d927c5aeb0d0629d329a20de43a64c423a6aa53836290609ef7ec/rpds_py-0.27.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1fea2b1a922c47c51fd07d656324531adc787e415c8b116530a1d29c0516c62d", size = 354086, upload-time = "2025-08-27T12:15:07.404Z" }, - { url = "https://files.pythonhosted.org/packages/9f/02/e43e332ad8ce4f6c4342d151a471a7f2900ed1d76901da62eb3762663a71/rpds_py-0.27.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbf94c58e8e0cd6b6f38d8de67acae41b3a515c26169366ab58bdca4a6883bb8", size = 382117, upload-time = "2025-08-27T12:15:09.275Z" }, - { url = "https://files.pythonhosted.org/packages/d0/05/b0fdeb5b577197ad72812bbdfb72f9a08fa1e64539cc3940b1b781cd3596/rpds_py-0.27.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c2a8fed130ce946d5c585eddc7c8eeef0051f58ac80a8ee43bd17835c144c2cc", size = 394520, upload-time = "2025-08-27T12:15:10.727Z" }, - { url = "https://files.pythonhosted.org/packages/67/1f/4cfef98b2349a7585181e99294fa2a13f0af06902048a5d70f431a66d0b9/rpds_py-0.27.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:037a2361db72ee98d829bc2c5b7cc55598ae0a5e0ec1823a56ea99374cfd73c1", size = 522657, upload-time = "2025-08-27T12:15:12.613Z" }, - { url = "https://files.pythonhosted.org/packages/44/55/ccf37ddc4c6dce7437b335088b5ca18da864b334890e2fe9aa6ddc3f79a9/rpds_py-0.27.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5281ed1cc1d49882f9997981c88df1a22e140ab41df19071222f7e5fc4e72125", size = 402967, upload-time = "2025-08-27T12:15:14.113Z" }, - { url = "https://files.pythonhosted.org/packages/74/e5/5903f92e41e293b07707d5bf00ef39a0eb2af7190aff4beaf581a6591510/rpds_py-0.27.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fd50659a069c15eef8aa3d64bbef0d69fd27bb4a50c9ab4f17f83a16cbf8905", size = 384372, upload-time = "2025-08-27T12:15:15.842Z" }, - { url = "https://files.pythonhosted.org/packages/8f/e3/fbb409e18aeefc01e49f5922ac63d2d914328430e295c12183ce56ebf76b/rpds_py-0.27.1-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:c4b676c4ae3921649a15d28ed10025548e9b561ded473aa413af749503c6737e", size = 401264, upload-time = "2025-08-27T12:15:17.388Z" }, - { url = "https://files.pythonhosted.org/packages/55/79/529ad07794e05cb0f38e2f965fc5bb20853d523976719400acecc447ec9d/rpds_py-0.27.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:079bc583a26db831a985c5257797b2b5d3affb0386e7ff886256762f82113b5e", size = 418691, upload-time = "2025-08-27T12:15:19.144Z" }, - { url = "https://files.pythonhosted.org/packages/33/39/6554a7fd6d9906fda2521c6d52f5d723dca123529fb719a5b5e074c15e01/rpds_py-0.27.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4e44099bd522cba71a2c6b97f68e19f40e7d85399de899d66cdb67b32d7cb786", size = 558989, upload-time = "2025-08-27T12:15:21.087Z" }, - { url = "https://files.pythonhosted.org/packages/19/b2/76fa15173b6f9f445e5ef15120871b945fb8dd9044b6b8c7abe87e938416/rpds_py-0.27.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e202e6d4188e53c6661af813b46c37ca2c45e497fc558bacc1a7630ec2695aec", size = 589835, upload-time = "2025-08-27T12:15:22.696Z" }, - { url = "https://files.pythonhosted.org/packages/ee/9e/5560a4b39bab780405bed8a88ee85b30178061d189558a86003548dea045/rpds_py-0.27.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:f41f814b8eaa48768d1bb551591f6ba45f87ac76899453e8ccd41dba1289b04b", size = 555227, upload-time = "2025-08-27T12:15:24.278Z" }, - { url = "https://files.pythonhosted.org/packages/52/d7/cd9c36215111aa65724c132bf709c6f35175973e90b32115dedc4ced09cb/rpds_py-0.27.1-cp39-cp39-win32.whl", hash = "sha256:9e71f5a087ead99563c11fdaceee83ee982fd39cf67601f4fd66cb386336ee52", size = 217899, upload-time = "2025-08-27T12:15:25.926Z" }, - { url = "https://files.pythonhosted.org/packages/5b/e0/d75ab7b4dd8ba777f6b365adbdfc7614bbfe7c5f05703031dfa4b61c3d6c/rpds_py-0.27.1-cp39-cp39-win_amd64.whl", hash = "sha256:71108900c9c3c8590697244b9519017a400d9ba26a36c48381b3f64743a44aab", size = 228725, upload-time = "2025-08-27T12:15:27.398Z" }, - { url = "https://files.pythonhosted.org/packages/d5/63/b7cc415c345625d5e62f694ea356c58fb964861409008118f1245f8c3347/rpds_py-0.27.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7ba22cb9693df986033b91ae1d7a979bc399237d45fccf875b76f62bb9e52ddf", size = 371360, upload-time = "2025-08-27T12:15:29.218Z" }, - { url = "https://files.pythonhosted.org/packages/e5/8c/12e1b24b560cf378b8ffbdb9dc73abd529e1adcfcf82727dfd29c4a7b88d/rpds_py-0.27.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5b640501be9288c77738b5492b3fd3abc4ba95c50c2e41273c8a1459f08298d3", size = 353933, upload-time = "2025-08-27T12:15:30.837Z" }, - { url = "https://files.pythonhosted.org/packages/9b/85/1bb2210c1f7a1b99e91fea486b9f0f894aa5da3a5ec7097cbad7dec6d40f/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb08b65b93e0c6dd70aac7f7890a9c0938d5ec71d5cb32d45cf844fb8ae47636", size = 382962, upload-time = "2025-08-27T12:15:32.348Z" }, - { url = "https://files.pythonhosted.org/packages/cc/c9/a839b9f219cf80ed65f27a7f5ddbb2809c1b85c966020ae2dff490e0b18e/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d7ff07d696a7a38152ebdb8212ca9e5baab56656749f3d6004b34ab726b550b8", size = 394412, upload-time = "2025-08-27T12:15:33.839Z" }, - { url = "https://files.pythonhosted.org/packages/02/2d/b1d7f928b0b1f4fc2e0133e8051d199b01d7384875adc63b6ddadf3de7e5/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fb7c72262deae25366e3b6c0c0ba46007967aea15d1eea746e44ddba8ec58dcc", size = 523972, upload-time = "2025-08-27T12:15:35.377Z" }, - { url = "https://files.pythonhosted.org/packages/a9/af/2cbf56edd2d07716df1aec8a726b3159deb47cb5c27e1e42b71d705a7c2f/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b002cab05d6339716b03a4a3a2ce26737f6231d7b523f339fa061d53368c9d8", size = 403273, upload-time = "2025-08-27T12:15:37.051Z" }, - { url = "https://files.pythonhosted.org/packages/c0/93/425e32200158d44ff01da5d9612c3b6711fe69f606f06e3895511f17473b/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23f6b69d1c26c4704fec01311963a41d7de3ee0570a84ebde4d544e5a1859ffc", size = 385278, upload-time = "2025-08-27T12:15:38.571Z" }, - { url = "https://files.pythonhosted.org/packages/eb/1a/1a04a915ecd0551bfa9e77b7672d1937b4b72a0fc204a17deef76001cfb2/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:530064db9146b247351f2a0250b8f00b289accea4596a033e94be2389977de71", size = 402084, upload-time = "2025-08-27T12:15:40.529Z" }, - { url = "https://files.pythonhosted.org/packages/51/f7/66585c0fe5714368b62951d2513b684e5215beaceab2c6629549ddb15036/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b90b0496570bd6b0321724a330d8b545827c4df2034b6ddfc5f5275f55da2ad", size = 419041, upload-time = "2025-08-27T12:15:42.191Z" }, - { url = "https://files.pythonhosted.org/packages/8e/7e/83a508f6b8e219bba2d4af077c35ba0e0cdd35a751a3be6a7cba5a55ad71/rpds_py-0.27.1-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:879b0e14a2da6a1102a3fc8af580fc1ead37e6d6692a781bd8c83da37429b5ab", size = 560084, upload-time = "2025-08-27T12:15:43.839Z" }, - { url = "https://files.pythonhosted.org/packages/66/66/bb945683b958a1b19eb0fe715594630d0f36396ebdef4d9b89c2fa09aa56/rpds_py-0.27.1-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:0d807710df3b5faa66c731afa162ea29717ab3be17bdc15f90f2d9f183da4059", size = 590115, upload-time = "2025-08-27T12:15:46.647Z" }, - { url = "https://files.pythonhosted.org/packages/12/00/ccfaafaf7db7e7adace915e5c2f2c2410e16402561801e9c7f96683002d3/rpds_py-0.27.1-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:3adc388fc3afb6540aec081fa59e6e0d3908722771aa1e37ffe22b220a436f0b", size = 556561, upload-time = "2025-08-27T12:15:48.219Z" }, - { url = "https://files.pythonhosted.org/packages/e1/b7/92b6ed9aad103bfe1c45df98453dfae40969eef2cb6c6239c58d7e96f1b3/rpds_py-0.27.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c796c0c1cc68cb08b0284db4229f5af76168172670c74908fdbd4b7d7f515819", size = 229125, upload-time = "2025-08-27T12:15:49.956Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ed/e1fba02de17f4f76318b834425257c8ea297e415e12c68b4361f63e8ae92/rpds_py-0.27.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:cdfe4bb2f9fe7458b7453ad3c33e726d6d1c7c0a72960bcc23800d77384e42df", size = 371402, upload-time = "2025-08-27T12:15:51.561Z" }, - { url = "https://files.pythonhosted.org/packages/af/7c/e16b959b316048b55585a697e94add55a4ae0d984434d279ea83442e460d/rpds_py-0.27.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:8fabb8fd848a5f75a2324e4a84501ee3a5e3c78d8603f83475441866e60b94a3", size = 354084, upload-time = "2025-08-27T12:15:53.219Z" }, - { url = "https://files.pythonhosted.org/packages/de/c1/ade645f55de76799fdd08682d51ae6724cb46f318573f18be49b1e040428/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eda8719d598f2f7f3e0f885cba8646644b55a187762bec091fa14a2b819746a9", size = 383090, upload-time = "2025-08-27T12:15:55.158Z" }, - { url = "https://files.pythonhosted.org/packages/1f/27/89070ca9b856e52960da1472efcb6c20ba27cfe902f4f23ed095b9cfc61d/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c64d07e95606ec402a0a1c511fe003873fa6af630bda59bac77fac8b4318ebc", size = 394519, upload-time = "2025-08-27T12:15:57.238Z" }, - { url = "https://files.pythonhosted.org/packages/b3/28/be120586874ef906aa5aeeae95ae8df4184bc757e5b6bd1c729ccff45ed5/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:93a2ed40de81bcff59aabebb626562d48332f3d028ca2036f1d23cbb52750be4", size = 523817, upload-time = "2025-08-27T12:15:59.237Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ef/70cc197bc11cfcde02a86f36ac1eed15c56667c2ebddbdb76a47e90306da/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:387ce8c44ae94e0ec50532d9cb0edce17311024c9794eb196b90e1058aadeb66", size = 403240, upload-time = "2025-08-27T12:16:00.923Z" }, - { url = "https://files.pythonhosted.org/packages/cf/35/46936cca449f7f518f2f4996e0e8344db4b57e2081e752441154089d2a5f/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaf94f812c95b5e60ebaf8bfb1898a7d7cb9c1af5744d4a67fa47796e0465d4e", size = 385194, upload-time = "2025-08-27T12:16:02.802Z" }, - { url = "https://files.pythonhosted.org/packages/e1/62/29c0d3e5125c3270b51415af7cbff1ec587379c84f55a5761cc9efa8cd06/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:4848ca84d6ded9b58e474dfdbad4b8bfb450344c0551ddc8d958bf4b36aa837c", size = 402086, upload-time = "2025-08-27T12:16:04.806Z" }, - { url = "https://files.pythonhosted.org/packages/8f/66/03e1087679227785474466fdd04157fb793b3b76e3fcf01cbf4c693c1949/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2bde09cbcf2248b73c7c323be49b280180ff39fadcfe04e7b6f54a678d02a7cf", size = 419272, upload-time = "2025-08-27T12:16:06.471Z" }, - { url = "https://files.pythonhosted.org/packages/6a/24/e3e72d265121e00b063aef3e3501e5b2473cf1b23511d56e529531acf01e/rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:94c44ee01fd21c9058f124d2d4f0c9dc7634bec93cd4b38eefc385dabe71acbf", size = 560003, upload-time = "2025-08-27T12:16:08.06Z" }, - { url = "https://files.pythonhosted.org/packages/26/ca/f5a344c534214cc2d41118c0699fffbdc2c1bc7046f2a2b9609765ab9c92/rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:df8b74962e35c9249425d90144e721eed198e6555a0e22a563d29fe4486b51f6", size = 590482, upload-time = "2025-08-27T12:16:10.137Z" }, - { url = "https://files.pythonhosted.org/packages/ce/08/4349bdd5c64d9d193c360aa9db89adeee6f6682ab8825dca0a3f535f434f/rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:dc23e6820e3b40847e2f4a7726462ba0cf53089512abe9ee16318c366494c17a", size = 556523, upload-time = "2025-08-27T12:16:12.188Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ea/5463cd5048a7a2fcdae308b6e96432802132c141bfb9420260142632a0f1/rpds_py-0.27.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:aa8933159edc50be265ed22b401125c9eebff3171f570258854dbce3ecd55475", size = 371778, upload-time = "2025-08-27T12:16:13.851Z" }, - { url = "https://files.pythonhosted.org/packages/0d/c8/f38c099db07f5114029c1467649d308543906933eebbc226d4527a5f4693/rpds_py-0.27.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:a50431bf02583e21bf273c71b89d710e7a710ad5e39c725b14e685610555926f", size = 354394, upload-time = "2025-08-27T12:16:15.609Z" }, - { url = "https://files.pythonhosted.org/packages/7d/79/b76f97704d9dd8ddbd76fed4c4048153a847c5d6003afe20a6b5c3339065/rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78af06ddc7fe5cc0e967085a9115accee665fb912c22a3f54bad70cc65b05fe6", size = 382348, upload-time = "2025-08-27T12:16:17.251Z" }, - { url = "https://files.pythonhosted.org/packages/8a/3f/ef23d3c1be1b837b648a3016d5bbe7cfe711422ad110b4081c0a90ef5a53/rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:70d0738ef8fee13c003b100c2fbd667ec4f133468109b3472d249231108283a3", size = 394159, upload-time = "2025-08-27T12:16:19.251Z" }, - { url = "https://files.pythonhosted.org/packages/74/8a/9e62693af1a34fd28b1a190d463d12407bd7cf561748cb4745845d9548d3/rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2f6fd8a1cea5bbe599b6e78a6e5ee08db434fc8ffea51ff201c8765679698b3", size = 522775, upload-time = "2025-08-27T12:16:20.929Z" }, - { url = "https://files.pythonhosted.org/packages/36/0d/8d5bb122bf7a60976b54c5c99a739a3819f49f02d69df3ea2ca2aff47d5c/rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8177002868d1426305bb5de1e138161c2ec9eb2d939be38291d7c431c4712df8", size = 402633, upload-time = "2025-08-27T12:16:22.548Z" }, - { url = "https://files.pythonhosted.org/packages/0f/0e/237948c1f425e23e0cf5a566d702652a6e55c6f8fbd332a1792eb7043daf/rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:008b839781d6c9bf3b6a8984d1d8e56f0ec46dc56df61fd669c49b58ae800400", size = 384867, upload-time = "2025-08-27T12:16:24.29Z" }, - { url = "https://files.pythonhosted.org/packages/d6/0a/da0813efcd998d260cbe876d97f55b0f469ada8ba9cbc47490a132554540/rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:a55b9132bb1ade6c734ddd2759c8dc132aa63687d259e725221f106b83a0e485", size = 401791, upload-time = "2025-08-27T12:16:25.954Z" }, - { url = "https://files.pythonhosted.org/packages/51/78/c6c9e8a8aaca416a6f0d1b6b4a6ee35b88fe2c5401d02235d0a056eceed2/rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a46fdec0083a26415f11d5f236b79fa1291c32aaa4a17684d82f7017a1f818b1", size = 419525, upload-time = "2025-08-27T12:16:27.659Z" }, - { url = "https://files.pythonhosted.org/packages/a3/69/5af37e1d71487cf6d56dd1420dc7e0c2732c1b6ff612aa7a88374061c0a8/rpds_py-0.27.1-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:8a63b640a7845f2bdd232eb0d0a4a2dd939bcdd6c57e6bb134526487f3160ec5", size = 559255, upload-time = "2025-08-27T12:16:29.343Z" }, - { url = "https://files.pythonhosted.org/packages/40/7f/8b7b136069ef7ac3960eda25d832639bdb163018a34c960ed042dd1707c8/rpds_py-0.27.1-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:7e32721e5d4922deaaf963469d795d5bde6093207c52fec719bd22e5d1bedbc4", size = 590384, upload-time = "2025-08-27T12:16:31.005Z" }, - { url = "https://files.pythonhosted.org/packages/d8/06/c316d3f6ff03f43ccb0eba7de61376f8ec4ea850067dddfafe98274ae13c/rpds_py-0.27.1-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:2c426b99a068601b5f4623573df7a7c3d72e87533a2dd2253353a03e7502566c", size = 555959, upload-time = "2025-08-27T12:16:32.73Z" }, - { url = "https://files.pythonhosted.org/packages/60/94/384cf54c430b9dac742bbd2ec26c23feb78ded0d43d6d78563a281aec017/rpds_py-0.27.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:4fc9b7fe29478824361ead6e14e4f5aed570d477e06088826537e202d25fe859", size = 228784, upload-time = "2025-08-27T12:16:34.428Z" }, -] - [[package]] name = "rpds-py" version = "0.30.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version == '3.10.*'", + "python_full_version < '3.11'", ] sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } wheels = [ @@ -3142,34 +2009,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102, upload-time = "2026-01-02T16:50:29.201Z" }, ] -[[package]] -name = "s3transfer" -version = "0.16.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.9.12' and python_full_version < '3.10'", - "python_full_version > '3.9' and python_full_version < '3.9.12'", - "python_full_version <= '3.9'", -] -dependencies = [ - { name = "botocore", version = "1.42.97", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/46/29/af14f4ef3c11a50435308660e2cc68761c9a7742475e0585cd4396b91777/s3transfer-0.16.1.tar.gz", hash = "sha256:8e424355754b9ccb32467bdc568edf55be82692ef2002d934b1311dbb3b9e524", size = 154801, upload-time = "2026-04-22T20:36:06.475Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/19/90d7d4ed51932c022d53f1d02d564b62d10e272692a1f9b76425c1ad2a02/s3transfer-0.16.1-py3-none-any.whl", hash = "sha256:61bcd00ccb83b21a0fe7e91a553fff9729d46c83b4e0106e7c314a733891f7c2", size = 86825, upload-time = "2026-04-22T20:36:04.992Z" }, -] - [[package]] name = "s3transfer" version = "0.19.2" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] dependencies = [ - { name = "botocore", version = "1.43.64", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "botocore" }, ] sdist = { url = "https://files.pythonhosted.org/packages/76/43/35e4d8aa320bffe8287fe8f65f578fa2d2db0a64212f0e710dce58267854/s3transfer-0.19.2.tar.gz", hash = "sha256:ba0309fd86be3c27dbf78cdd813c13c5e1df16e5874b99d2535ebbdfb9892993", size = 165592, upload-time = "2026-07-22T19:30:44.432Z" } wheels = [ @@ -3187,12 +2032,10 @@ dependencies = [ [package.optional-dependencies] auth-kerberos = [ { name = "kerberos", marker = "sys_platform != 'win32'" }, - { name = "winkerberos", version = "0.12.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, - { name = "winkerberos", version = "0.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "winkerberos", marker = "sys_platform == 'win32'" }, ] cle = [ - { name = "cryptography", version = "47.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.9'" }, - { name = "cryptography", version = "50.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.9'" }, + { name = "cryptography" }, ] compress-lz4 = [ { name = "lz4" }, @@ -3201,22 +2044,17 @@ compress-snappy = [ { name = "python-snappy" }, ] graph = [ - { name = "gremlinpython", version = "3.7.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "gremlinpython", version = "3.8.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "gremlinpython" }, ] [package.dev-dependencies] dev = [ { name = "ccm" }, - { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version < '3.10'" }, - { name = "coverage", version = "7.15.3", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version >= '3.10'" }, - { name = "cryptography", version = "47.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.9'" }, - { name = "cryptography", version = "50.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.9'" }, + { name = "coverage", extra = ["toml"] }, + { name = "cryptography" }, { name = "cython" }, - { name = "jsonschema", version = "4.25.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "jsonschema", version = "4.26.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "jsonschema" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "objgraph" }, @@ -3224,8 +2062,7 @@ dev = [ { name = "pure-sasl" }, { name = "pytest" }, { name = "pyyaml" }, - { name = "setuptools", version = "82.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "setuptools", version = "83.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "setuptools" }, ] [package.metadata] @@ -3252,34 +2089,15 @@ dev = [ { name = "objgraph" }, { name = "packaging", specifier = ">=25.0" }, { name = "pure-sasl" }, - { name = "pytest", specifier = "~=8.0" }, + { name = "pytest", specifier = "~=9.1" }, { name = "pyyaml" }, { name = "setuptools" }, ] -[[package]] -name = "setuptools" -version = "82.0.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.9.12' and python_full_version < '3.10'", - "python_full_version > '3.9' and python_full_version < '3.9.12'", - "python_full_version <= '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, -] - [[package]] name = "setuptools" version = "83.0.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, @@ -3369,66 +2187,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] -[[package]] -name = "urllib3" -version = "1.26.20" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.9.12' and python_full_version < '3.10'", - "python_full_version > '3.9' and python_full_version < '3.9.12'", - "python_full_version <= '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/e4/e8/6ff5e6bc22095cfc59b6ea711b687e2b7ed4bdb373f7eeec370a97d7392f/urllib3-1.26.20.tar.gz", hash = "sha256:40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32", size = 307380, upload-time = "2024-08-29T15:43:11.37Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/33/cf/8435d5a7159e2a9c83a95896ed596f68cf798005fe107cc655b5c5c14704/urllib3-1.26.20-py2.py3-none-any.whl", hash = "sha256:0ed14ccfbf1c30a9072c7ca157e4319b70d65f623e91e7b32fadb2853431016e", size = 144225, upload-time = "2024-08-29T15:43:08.921Z" }, -] - [[package]] name = "urllib3" version = "2.7.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] -[[package]] -name = "winkerberos" -version = "0.12.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.9.12' and python_full_version < '3.10'", - "python_full_version > '3.9' and python_full_version < '3.9.12'", - "python_full_version <= '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/2d/75/86d470935167eb1c40d53498993e14cc021d9611a539d61c9b4202c291ab/winkerberos-0.12.2.tar.gz", hash = "sha256:ff91daed04727a0362892802ee093d8da11f08536393526bdf3bc64e04079faa", size = 35672, upload-time = "2025-04-02T14:41:48.274Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/59/ac/c6ce495af45371ffd85a6a3d24c2ced679b8dbcf3b8c6beca093706b1620/winkerberos-0.12.2-cp310-cp310-win32.whl", hash = "sha256:f8b751bd5a28e6a9146f154bed395c30ce4f245448addc763f98cb8843879027", size = 25331, upload-time = "2025-04-02T14:41:36.398Z" }, - { url = "https://files.pythonhosted.org/packages/cb/7b/ad32174c3ed4710cd2ad8f20171f5061cb13603f091d714d5aa6b30d51f0/winkerberos-0.12.2-cp310-cp310-win_amd64.whl", hash = "sha256:4be3b0de548b80f52a6544dff9d571da6cdfde590176a01477358b3808b12dfa", size = 27670, upload-time = "2025-04-02T14:41:37.68Z" }, - { url = "https://files.pythonhosted.org/packages/91/12/23b29d359dee9f7a8243cb0040ea1834acd1af8cbc38cfe1c7ca82ab4ec0/winkerberos-0.12.2-cp311-cp311-win32.whl", hash = "sha256:ff2b2ec9b9246bbc05f0d4e6fe5f3f3563237357b9b35eaa58ec1a9ddf349ab8", size = 25332, upload-time = "2025-04-02T14:41:38.671Z" }, - { url = "https://files.pythonhosted.org/packages/23/d2/2bfa1dcdb4a47b7f989a9e758c892bd7393a156b0e1f0df63eca8304e892/winkerberos-0.12.2-cp311-cp311-win_amd64.whl", hash = "sha256:e6ac2b2cc329a68502821905f6ffe48e109d54a46aba7414ea231a30c75bb2d9", size = 27671, upload-time = "2025-04-02T14:41:40.104Z" }, - { url = "https://files.pythonhosted.org/packages/4f/01/26c5b1435654596c07b314653183ffe42b64ea07041c328f0fd4c68fe9f9/winkerberos-0.12.2-cp312-cp312-win32.whl", hash = "sha256:46dac1300e20738cbaf6c17c2e4832062ed7faee346c7a96f0e57f8bbe279c25", size = 25396, upload-time = "2025-04-02T14:41:41.6Z" }, - { url = "https://files.pythonhosted.org/packages/64/b1/6c4a1e4e50553798eb44dbb0d71ba6af48e2a62a0eb01bd0d4e2b41914e3/winkerberos-0.12.2-cp312-cp312-win_amd64.whl", hash = "sha256:2c5c7a70c0d4a43546b20d5654e7e7e5e5e96f42084a7f293864f7ad0fb1e953", size = 27710, upload-time = "2025-04-02T14:41:42.656Z" }, - { url = "https://files.pythonhosted.org/packages/5f/91/cff6750c7c3b2a9f35e12cd7c4df901251fc3be985edef707a3458c43e9a/winkerberos-0.12.2-cp313-cp313-win32.whl", hash = "sha256:482a72500b7822cc8f941d0c6eed668a24c030ac145c97732e175b51441bebbf", size = 25391, upload-time = "2025-04-02T14:41:43.938Z" }, - { url = "https://files.pythonhosted.org/packages/2a/98/defb037ad127c4006c4e992dd55ce0df92059626d3df5f5f4c5fc8502c26/winkerberos-0.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:efd65ba54534512070916cb9c91ef9798a0f9fb0b04e12732c9631e71553fd69", size = 27704, upload-time = "2025-04-02T14:41:45.203Z" }, - { url = "https://files.pythonhosted.org/packages/be/17/b16e72e0b896cdf05666994cbc402a66f5911d56ea28d4e858714328b698/winkerberos-0.12.2-cp39-cp39-win32.whl", hash = "sha256:0c80eed53472a38d7f1dd015e27d93705b22a2acd2557bad13d8b5d688037b29", size = 25326, upload-time = "2025-04-02T14:41:46.216Z" }, - { url = "https://files.pythonhosted.org/packages/65/04/ae42e839e8d836fde613f94f30395953292a7b9be388247237196d1e5caa/winkerberos-0.12.2-cp39-cp39-win_amd64.whl", hash = "sha256:4b908aab5ab42e98bee44eca67dfebe4733d210bccf021e42b669bf4af2005a4", size = 27663, upload-time = "2025-04-02T14:41:47.294Z" }, -] - [[package]] name = "winkerberos" version = "0.13.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] sdist = { url = "https://files.pythonhosted.org/packages/ca/6c/455f043bc28694a278125d1fc2ab7cbf0ce0953c97bbe1021f08fd19c7b8/winkerberos-0.13.0.tar.gz", hash = "sha256:f3fbb67346fe8ed697e125724b0699d5c2a15b9a5f9151d25a1be88df8dac427", size = 35677, upload-time = "2025-12-03T14:17:29.882Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/05/45/7199a756e3b25757cbf5986c8af040647aba24b039493eddff7950007f31/winkerberos-0.13.0-cp310-cp310-win32.whl", hash = "sha256:e6df7ab4c4e39e3e1d539b32ea20df84dc7ac32391391bf415c2a8051082051d", size = 25637, upload-time = "2025-12-03T14:17:15.236Z" }, @@ -3445,166 +2216,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/a6/cc5f24b3f1a46a826b7e30ef56fdc1fe22315fef96de8e22afbdd5d98e7a/winkerberos-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:441884c0bda4bee0125fdbd7fee6a232dab58b4a64be8950eb17a8a7404a5440", size = 28715, upload-time = "2025-12-03T14:17:28.813Z" }, ] -[[package]] -name = "yarl" -version = "1.22.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.9.12' and python_full_version < '3.10'", - "python_full_version > '3.9' and python_full_version < '3.9.12'", - "python_full_version <= '3.9'", -] -dependencies = [ - { name = "idna", marker = "python_full_version < '3.10'" }, - { name = "multidict", marker = "python_full_version < '3.10'" }, - { name = "propcache", version = "0.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/43/a2204825342f37c337f5edb6637040fa14e365b2fcc2346960201d457579/yarl-1.22.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c7bd6683587567e5a49ee6e336e0612bec8329be1b7d4c8af5687dcdeb67ee1e", size = 140517, upload-time = "2025-10-06T14:08:42.494Z" }, - { url = "https://files.pythonhosted.org/packages/44/6f/674f3e6f02266428c56f704cd2501c22f78e8b2eeb23f153117cc86fb28a/yarl-1.22.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5cdac20da754f3a723cceea5b3448e1a2074866406adeb4ef35b469d089adb8f", size = 93495, upload-time = "2025-10-06T14:08:46.2Z" }, - { url = "https://files.pythonhosted.org/packages/b8/12/5b274d8a0f30c07b91b2f02cba69152600b47830fcfb465c108880fcee9c/yarl-1.22.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07a524d84df0c10f41e3ee918846e1974aba4ec017f990dc735aad487a0bdfdf", size = 94400, upload-time = "2025-10-06T14:08:47.855Z" }, - { url = "https://files.pythonhosted.org/packages/e2/7f/df1b6949b1fa1aa9ff6de6e2631876ad4b73c4437822026e85d8acb56bb1/yarl-1.22.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1b329cb8146d7b736677a2440e422eadd775d1806a81db2d4cded80a48efc1a", size = 347545, upload-time = "2025-10-06T14:08:49.683Z" }, - { url = "https://files.pythonhosted.org/packages/84/09/f92ed93bd6cd77872ab6c3462df45ca45cd058d8f1d0c9b4f54c1704429f/yarl-1.22.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75976c6945d85dbb9ee6308cd7ff7b1fb9409380c82d6119bd778d8fcfe2931c", size = 319598, upload-time = "2025-10-06T14:08:51.215Z" }, - { url = "https://files.pythonhosted.org/packages/c3/97/ac3f3feae7d522cf7ccec3d340bb0b2b61c56cb9767923df62a135092c6b/yarl-1.22.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:80ddf7a5f8c86cb3eb4bc9028b07bbbf1f08a96c5c0bc1244be5e8fefcb94147", size = 363893, upload-time = "2025-10-06T14:08:53.144Z" }, - { url = "https://files.pythonhosted.org/packages/06/49/f3219097403b9c84a4d079b1d7bda62dd9b86d0d6e4428c02d46ab2c77fc/yarl-1.22.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d332fc2e3c94dad927f2112395772a4e4fedbcf8f80efc21ed7cdfae4d574fdb", size = 371240, upload-time = "2025-10-06T14:08:55.036Z" }, - { url = "https://files.pythonhosted.org/packages/35/9f/06b765d45c0e44e8ecf0fe15c9eacbbde342bb5b7561c46944f107bfb6c3/yarl-1.22.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cf71bf877efeac18b38d3930594c0948c82b64547c1cf420ba48722fe5509f6", size = 346965, upload-time = "2025-10-06T14:08:56.722Z" }, - { url = "https://files.pythonhosted.org/packages/c5/69/599e7cea8d0fcb1694323b0db0dda317fa3162f7b90166faddecf532166f/yarl-1.22.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:663e1cadaddae26be034a6ab6072449a8426ddb03d500f43daf952b74553bba0", size = 342026, upload-time = "2025-10-06T14:08:58.563Z" }, - { url = "https://files.pythonhosted.org/packages/95/6f/9dfd12c8bc90fea9eab39832ee32ea48f8e53d1256252a77b710c065c89f/yarl-1.22.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:6dcbb0829c671f305be48a7227918cfcd11276c2d637a8033a99a02b67bf9eda", size = 335637, upload-time = "2025-10-06T14:09:00.506Z" }, - { url = "https://files.pythonhosted.org/packages/57/2e/34c5b4eb9b07e16e873db5b182c71e5f06f9b5af388cdaa97736d79dd9a6/yarl-1.22.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f0d97c18dfd9a9af4490631905a3f131a8e4c9e80a39353919e2cfed8f00aedc", size = 359082, upload-time = "2025-10-06T14:09:01.936Z" }, - { url = "https://files.pythonhosted.org/packages/31/71/fa7e10fb772d273aa1f096ecb8ab8594117822f683bab7d2c5a89914c92a/yarl-1.22.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:437840083abe022c978470b942ff832c3940b2ad3734d424b7eaffcd07f76737", size = 357811, upload-time = "2025-10-06T14:09:03.445Z" }, - { url = "https://files.pythonhosted.org/packages/26/da/11374c04e8e1184a6a03cf9c8f5688d3e5cec83ed6f31ad3481b3207f709/yarl-1.22.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a899cbd98dce6f5d8de1aad31cb712ec0a530abc0a86bd6edaa47c1090138467", size = 351223, upload-time = "2025-10-06T14:09:05.401Z" }, - { url = "https://files.pythonhosted.org/packages/82/8f/e2d01f161b0c034a30410e375e191a5d27608c1f8693bab1a08b089ca096/yarl-1.22.0-cp310-cp310-win32.whl", hash = "sha256:595697f68bd1f0c1c159fcb97b661fc9c3f5db46498043555d04805430e79bea", size = 82118, upload-time = "2025-10-06T14:09:11.148Z" }, - { url = "https://files.pythonhosted.org/packages/62/46/94c76196642dbeae634c7a61ba3da88cd77bed875bf6e4a8bed037505aa6/yarl-1.22.0-cp310-cp310-win_amd64.whl", hash = "sha256:cb95a9b1adaa48e41815a55ae740cfda005758104049a640a398120bf02515ca", size = 86852, upload-time = "2025-10-06T14:09:12.958Z" }, - { url = "https://files.pythonhosted.org/packages/af/af/7df4f179d3b1a6dcb9a4bd2ffbc67642746fcafdb62580e66876ce83fff4/yarl-1.22.0-cp310-cp310-win_arm64.whl", hash = "sha256:b85b982afde6df99ecc996990d4ad7ccbdbb70e2a4ba4de0aecde5922ba98a0b", size = 82012, upload-time = "2025-10-06T14:09:14.664Z" }, - { url = "https://files.pythonhosted.org/packages/4d/27/5ab13fc84c76a0250afd3d26d5936349a35be56ce5785447d6c423b26d92/yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511", size = 141607, upload-time = "2025-10-06T14:09:16.298Z" }, - { url = "https://files.pythonhosted.org/packages/6a/a1/d065d51d02dc02ce81501d476b9ed2229d9a990818332242a882d5d60340/yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6", size = 94027, upload-time = "2025-10-06T14:09:17.786Z" }, - { url = "https://files.pythonhosted.org/packages/c1/da/8da9f6a53f67b5106ffe902c6fa0164e10398d4e150d85838b82f424072a/yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028", size = 94963, upload-time = "2025-10-06T14:09:19.662Z" }, - { url = "https://files.pythonhosted.org/packages/68/fe/2c1f674960c376e29cb0bec1249b117d11738db92a6ccc4a530b972648db/yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d", size = 368406, upload-time = "2025-10-06T14:09:21.402Z" }, - { url = "https://files.pythonhosted.org/packages/95/26/812a540e1c3c6418fec60e9bbd38e871eaba9545e94fa5eff8f4a8e28e1e/yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503", size = 336581, upload-time = "2025-10-06T14:09:22.98Z" }, - { url = "https://files.pythonhosted.org/packages/0b/f5/5777b19e26fdf98563985e481f8be3d8a39f8734147a6ebf459d0dab5a6b/yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65", size = 388924, upload-time = "2025-10-06T14:09:24.655Z" }, - { url = "https://files.pythonhosted.org/packages/86/08/24bd2477bd59c0bbd994fe1d93b126e0472e4e3df5a96a277b0a55309e89/yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e", size = 392890, upload-time = "2025-10-06T14:09:26.617Z" }, - { url = "https://files.pythonhosted.org/packages/46/00/71b90ed48e895667ecfb1eaab27c1523ee2fa217433ed77a73b13205ca4b/yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d", size = 365819, upload-time = "2025-10-06T14:09:28.544Z" }, - { url = "https://files.pythonhosted.org/packages/30/2d/f715501cae832651d3282387c6a9236cd26bd00d0ff1e404b3dc52447884/yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7", size = 363601, upload-time = "2025-10-06T14:09:30.568Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f9/a678c992d78e394e7126ee0b0e4e71bd2775e4334d00a9278c06a6cce96a/yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967", size = 358072, upload-time = "2025-10-06T14:09:32.528Z" }, - { url = "https://files.pythonhosted.org/packages/2c/d1/b49454411a60edb6fefdcad4f8e6dbba7d8019e3a508a1c5836cba6d0781/yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed", size = 385311, upload-time = "2025-10-06T14:09:34.634Z" }, - { url = "https://files.pythonhosted.org/packages/87/e5/40d7a94debb8448c7771a916d1861d6609dddf7958dc381117e7ba36d9e8/yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6", size = 381094, upload-time = "2025-10-06T14:09:36.268Z" }, - { url = "https://files.pythonhosted.org/packages/35/d8/611cc282502381ad855448643e1ad0538957fc82ae83dfe7762c14069e14/yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e", size = 370944, upload-time = "2025-10-06T14:09:37.872Z" }, - { url = "https://files.pythonhosted.org/packages/2d/df/fadd00fb1c90e1a5a8bd731fa3d3de2e165e5a3666a095b04e31b04d9cb6/yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca", size = 81804, upload-time = "2025-10-06T14:09:39.359Z" }, - { url = "https://files.pythonhosted.org/packages/b5/f7/149bb6f45f267cb5c074ac40c01c6b3ea6d8a620d34b337f6321928a1b4d/yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b", size = 86858, upload-time = "2025-10-06T14:09:41.068Z" }, - { url = "https://files.pythonhosted.org/packages/2b/13/88b78b93ad3f2f0b78e13bfaaa24d11cbc746e93fe76d8c06bf139615646/yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376", size = 81637, upload-time = "2025-10-06T14:09:42.712Z" }, - { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" }, - { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" }, - { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" }, - { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" }, - { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" }, - { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" }, - { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" }, - { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" }, - { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" }, - { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" }, - { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" }, - { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" }, - { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" }, - { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" }, - { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" }, - { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" }, - { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" }, - { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" }, - { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" }, - { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" }, - { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" }, - { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" }, - { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" }, - { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" }, - { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" }, - { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" }, - { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" }, - { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" }, - { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" }, - { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" }, - { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" }, - { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" }, - { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" }, - { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" }, - { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" }, - { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" }, - { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" }, - { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" }, - { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" }, - { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" }, - { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" }, - { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" }, - { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" }, - { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" }, - { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" }, - { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" }, - { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" }, - { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" }, - { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" }, - { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" }, - { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" }, - { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" }, - { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" }, - { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" }, - { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" }, - { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" }, - { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" }, - { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" }, - { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" }, - { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" }, - { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" }, - { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" }, - { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" }, - { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" }, - { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" }, - { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" }, - { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" }, - { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" }, - { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" }, - { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" }, - { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" }, - { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" }, - { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" }, - { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" }, - { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" }, - { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" }, - { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" }, - { url = "https://files.pythonhosted.org/packages/94/fd/6480106702a79bcceda5fd9c63cb19a04a6506bd5ce7fd8d9b63742f0021/yarl-1.22.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3aa27acb6de7a23785d81557577491f6c38a5209a254d1191519d07d8fe51748", size = 141301, upload-time = "2025-10-06T14:12:19.01Z" }, - { url = "https://files.pythonhosted.org/packages/42/e1/6d95d21b17a93e793e4ec420a925fe1f6a9342338ca7a563ed21129c0990/yarl-1.22.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:af74f05666a5e531289cb1cc9c883d1de2088b8e5b4de48004e5ca8a830ac859", size = 93864, upload-time = "2025-10-06T14:12:21.05Z" }, - { url = "https://files.pythonhosted.org/packages/32/58/b8055273c203968e89808413ea4c984988b6649baabf10f4522e67c22d2f/yarl-1.22.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:62441e55958977b8167b2709c164c91a6363e25da322d87ae6dd9c6019ceecf9", size = 94706, upload-time = "2025-10-06T14:12:23.287Z" }, - { url = "https://files.pythonhosted.org/packages/18/91/d7bfbc28a88c2895ecd0da6a874def0c147de78afc52c773c28e1aa233a3/yarl-1.22.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b580e71cac3f8113d3135888770903eaf2f507e9421e5697d6ee6d8cd1c7f054", size = 347100, upload-time = "2025-10-06T14:12:28.527Z" }, - { url = "https://files.pythonhosted.org/packages/bd/e8/37a1e7b99721c0564b1fc7b0a4d1f595ef6fb8060d82ca61775b644185f7/yarl-1.22.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e81fda2fb4a07eda1a2252b216aa0df23ebcd4d584894e9612e80999a78fd95b", size = 318902, upload-time = "2025-10-06T14:12:30.528Z" }, - { url = "https://files.pythonhosted.org/packages/1c/ef/34724449d7ef2db4f22df644f2dac0b8a275d20f585e526937b3ae47b02d/yarl-1.22.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99b6fc1d55782461b78221e95fc357b47ad98b041e8e20f47c1411d0aacddc60", size = 363302, upload-time = "2025-10-06T14:12:32.295Z" }, - { url = "https://files.pythonhosted.org/packages/8a/04/88a39a5dad39889f192cce8d66cc4c58dbeca983e83f9b6bf23822a7ed91/yarl-1.22.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:088e4e08f033db4be2ccd1f34cf29fe994772fb54cfe004bbf54db320af56890", size = 370816, upload-time = "2025-10-06T14:12:34.01Z" }, - { url = "https://files.pythonhosted.org/packages/6b/1f/5e895e547129413f56c76be2c3ce4b96c797d2d0ff3e16a817d9269b12e6/yarl-1.22.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e4e1f6f0b4da23e61188676e3ed027ef0baa833a2e633c29ff8530800edccba", size = 346465, upload-time = "2025-10-06T14:12:35.977Z" }, - { url = "https://files.pythonhosted.org/packages/11/13/a750e9fd6f9cc9ed3a52a70fe58ffe505322f0efe0d48e1fd9ffe53281f5/yarl-1.22.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:84fc3ec96fce86ce5aa305eb4aa9358279d1aa644b71fab7b8ed33fe3ba1a7ca", size = 341506, upload-time = "2025-10-06T14:12:37.788Z" }, - { url = "https://files.pythonhosted.org/packages/3c/67/bb6024de76e7186611ebe626aec5b71a2d2ecf9453e795f2dbd80614784c/yarl-1.22.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5dbeefd6ca588b33576a01b0ad58aa934bc1b41ef89dee505bf2932b22ddffba", size = 335030, upload-time = "2025-10-06T14:12:39.775Z" }, - { url = "https://files.pythonhosted.org/packages/a2/be/50b38447fd94a7992996a62b8b463d0579323fcfc08c61bdba949eef8a5d/yarl-1.22.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:14291620375b1060613f4aab9ebf21850058b6b1b438f386cc814813d901c60b", size = 358560, upload-time = "2025-10-06T14:12:41.547Z" }, - { url = "https://files.pythonhosted.org/packages/e2/89/c020b6f547578c4e3dbb6335bf918f26e2f34ad0d1e515d72fd33ac0c635/yarl-1.22.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:a4fcfc8eb2c34148c118dfa02e6427ca278bfd0f3df7c5f99e33d2c0e81eae3e", size = 357290, upload-time = "2025-10-06T14:12:43.861Z" }, - { url = "https://files.pythonhosted.org/packages/8c/52/c49a619ee35a402fa3a7019a4fa8d26878fec0d1243f6968bbf516789578/yarl-1.22.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:029866bde8d7b0878b9c160e72305bbf0a7342bcd20b9999381704ae03308dc8", size = 350700, upload-time = "2025-10-06T14:12:46.868Z" }, - { url = "https://files.pythonhosted.org/packages/ab/c9/f5042d87777bf6968435f04a2bbb15466b2f142e6e47fa4f34d1a3f32f0c/yarl-1.22.0-cp39-cp39-win32.whl", hash = "sha256:4dcc74149ccc8bba31ce1944acee24813e93cfdee2acda3c172df844948ddf7b", size = 82323, upload-time = "2025-10-06T14:12:48.633Z" }, - { url = "https://files.pythonhosted.org/packages/fd/58/d00f7cad9eba20c4eefac2682f34661d1d1b3a942fc0092eb60e78cfb733/yarl-1.22.0-cp39-cp39-win_amd64.whl", hash = "sha256:10619d9fdee46d20edc49d3479e2f8269d0779f1b031e6f7c2aa1c76be04b7ed", size = 87145, upload-time = "2025-10-06T14:12:50.241Z" }, - { url = "https://files.pythonhosted.org/packages/c2/a3/70904f365080780d38b919edd42d224b8c4ce224a86950d2eaa2a24366ad/yarl-1.22.0-cp39-cp39-win_arm64.whl", hash = "sha256:dd7afd3f8b0bfb4e0d9fc3c31bfe8a4ec7debe124cfd90619305def3c8ca8cd2", size = 82173, upload-time = "2025-10-06T14:12:51.869Z" }, - { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, -] - [[package]] name = "yarl" version = "1.24.5" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] dependencies = [ - { name = "idna", marker = "python_full_version >= '3.10'" }, - { name = "multidict", marker = "python_full_version >= '3.10'" }, - { name = "propcache", version = "0.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, ] sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" } wheels = [ From fb8cac6bbc9e5e10f0417aa4a665c07889777055 Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Thu, 24 Sep 2026 20:42:53 -0400 Subject: [PATCH 194/200] cluster: stop schedulers before executor teardown (#392) * cluster: stop schedulers before executor teardown ThreadPoolExecutor disables new submissions before ordinary atexit callbacks run. A cluster scheduler can otherwise wake during interpreter shutdown and fail while submitting work to its executor. Register scheduler cleanup through the threading shutdown hook, which runs before executor teardown, while keeping full cluster cleanup in regular atexit so application threads finish before connections close. Coordinate cleanup with cluster registration so a non-daemon thread cannot register a scheduler after the cleanup snapshot. Preserve warm imports after threading shutdown starts and allow remaining shutdown callbacks to run when scheduler cleanup fails. Add isolated subprocess coverage for shutdown ordering, late registration, cleanup failures, and warm late imports. * cluster: reject connects after late import * cluster: fail retries when scheduler stops * cluster: serialize retry abort completion Finalize scheduler-aborted retries under the ResponseFuture callback lock so a concurrent successful response cannot invoke both callback paths or replace the shutdown outcome. Preserve the existing behavior for non-shutdown completions, where a late successful response may supersede an earlier error. Add coverage for both completion orders. * control-connection: fail fallback USE retry on shutdown Route the internal keyspace-selection retry through the shutdown-aware scheduler so session or executor teardown cannot strand a response future. Add coverage for retries arriving after session shutdown and scheduler rejection. * cluster: harden scheduler shutdown completion Make scheduler shutdown wait for an in-flight owner and drain queued callbacks only after its thread exits. This keeps concurrent cluster cleanup ordered without deadlocking callbacks that re-enter shutdown. Scope retry-abort finalization to the result page that scheduled it. Preserve ordinary response completion semantics, reject paging after terminal scheduler shutdown, and ignore stale abort callbacks from completed pages. * control-connection: exclude internal USE from retry metrics Keep the fallback keyspace-selection retry on the shutdown-aware scheduler without reporting it as a RetryPolicy decision. Preserve metric accounting for ordinary policy retries and cover both paths. * cluster: make scheduler rejection explicit Return late shutdown state from cluster registration so connect owns the cleanup and error at the call site without weakening the atomic registry check. Propagate schedule_unique acceptance consistently with other scheduler entry points. Limit the sentinel test time mock to the cluster module so unrelated background threads cannot make it fail on Windows. * cluster: release scheduler lock before submission Claim and remove due tasks while holding the scheduler lock, then submit them after releasing it. Scheduler shutdown still joins the thread before executor teardown, preserving ordering while allowing event-loop threads to schedule without blocking behind executor submission. Cover the lock boundary with a blocking executor submission and a concurrent schedule call. * cluster: harden scheduler shutdown edge cases Clear inherited cluster registrations and replace their lock in fork children so child thread teardown cannot run parent callbacks or deadlock on locks held at fork. Allow child-created clusters to register normally. Treat executor submission rejection as terminal scheduler shutdown. Requeue the claimed task for ordered draining so concurrent shutdown owners and reentrant callbacks complete without stranding retries. Cover both paths with fork and concurrency regression tests. * cluster: reject late connects after scheduler cleanup Check the global scheduler-shutdown state on every Cluster.connect call so an initialized cluster cannot create a new session after its scheduler has stopped. Preserve the existing first-connect reactor initialization order. Cover the repeated-connect path with an isolated shutdown subprocess. * cluster: avoid executor joins on late connects Reject repeated connects after scheduler cleanup without closing an initialized cluster before application threads finish. Keep its registered ordinary atexit cleanup. For fresh clusters, claim shutdown under the cluster lock and clean up after releasing it. Once scheduler cleanup starts, avoid waiting on executor teardown so callbacks can re-enter shutdown without deadlocking or joining themselves. Cover own-executor late connects and reentrant shutdown. * cluster: narrow scheduler shutdown handling Keep interpreter lifecycle ordering and queued statement retry completion in this change. Restore the existing control-connection fallback handoff, executor-rejection behavior, and schedule_unique contract so their separate failure semantics can be handled independently. Defer internal handoff completion to #1044 and executor-rejection recovery to #1045. --- cassandra/cluster.py | 293 +++++++++++--- tests/unit/test_cluster.py | 597 +++++++++++++++++++++++++++++ tests/unit/test_response_future.py | 188 ++++++++- 3 files changed, 1024 insertions(+), 54 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 2f9f9426fb..4339a74cab 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -30,6 +30,7 @@ import enum import json import logging +import os from typing import Any, Dict, Optional, Union, Tuple from warnings import warn from random import random @@ -37,9 +38,14 @@ import queue import socket import time -from threading import Lock, RLock, Thread, Event +from threading import get_ident, Lock, RLock, Thread, Event import uuid +try: + from threading import _register_atexit as _register_threading_atexit +except ImportError: + _register_threading_atexit = None + import weakref from weakref import WeakValueDictionary @@ -207,22 +213,81 @@ def new_f(self, *args, **kwargs): _clusters_for_shutdown = set() +_clusters_for_shutdown_lock = Lock() +_cluster_scheduler_shutdown_started = False + + +def _clear_clusters_for_shutdown_after_fork(): + """Forget parent-owned clusters and locks in a fork child.""" + global _clusters_for_shutdown_lock, _cluster_scheduler_shutdown_started + _clusters_for_shutdown_lock = Lock() + _cluster_scheduler_shutdown_started = False + _clusters_for_shutdown.clear() def _register_cluster_shutdown(cluster): - _clusters_for_shutdown.add(cluster) + """Track a cluster unless scheduler shutdown has already started.""" + with _clusters_for_shutdown_lock: + if not _cluster_scheduler_shutdown_started: + _clusters_for_shutdown.add(cluster) + return True + return False def _discard_cluster_shutdown(cluster): - _clusters_for_shutdown.discard(cluster) + """Stop tracking a cluster after explicit shutdown.""" + with _clusters_for_shutdown_lock: + _clusters_for_shutdown.discard(cluster) + + +def _shutdown_cluster_schedulers(): + """Stop registered schedulers without aborting interpreter shutdown.""" + global _cluster_scheduler_shutdown_started + with _clusters_for_shutdown_lock: + _cluster_scheduler_shutdown_started = True + clusters = _clusters_for_shutdown.copy() + for cluster in clusters: + try: + cluster.scheduler.shutdown() + except Exception: + # Exceptions from threading atexit callbacks prevent the remaining + # callbacks and non-daemon thread joins from running. + log.exception("Failed to shut down Cluster scheduler") def _shutdown_clusters(): - clusters = _clusters_for_shutdown.copy() # copy because shutdown modifies the global set "discard" + """Shut down registered clusters during normal atexit processing.""" + with _clusters_for_shutdown_lock: + # Copy because shutdown removes clusters from the global set. + clusters = _clusters_for_shutdown.copy() for cluster in clusters: cluster.shutdown() +if hasattr(os, 'register_at_fork'): + os.register_at_fork(after_in_child=_clear_clusters_for_shutdown_after_fork) + + +# concurrent.futures is imported before this registration and installs its +# _python_exit callback first. threading runs callbacks in reverse order, so +# schedulers stop before ThreadPoolExecutor disables submissions. Full cluster +# shutdown remains an ordinary atexit callback so application threads finish +# before their connections close. +if _register_threading_atexit is not None: + try: + _register_threading_atexit(_shutdown_cluster_schedulers) + except RuntimeError: + # Keep imports from failing when concurrent.futures was initialized before + # threading shutdown started. No scheduler cleanup callback can run now, + # so reject clusters through the same path as completed cleanup. + with _clusters_for_shutdown_lock: + _cluster_scheduler_shutdown_started = True + log.warning("Could not register Cluster scheduler shutdown during interpreter shutdown") +else: + # Unknown runtimes may not expose CPython's pre-thread-shutdown hook. Keep + # the driver importable there, but make the loss of early cleanup visible. + log.warning("Cluster schedulers cannot be stopped before executor teardown: " + "threading._register_atexit is unavailable") atexit.register(_shutdown_clusters) @@ -1915,6 +1980,8 @@ def connect(self, keyspace=None, wait_for_all_pools=False): established or attempted. Default is `False`, which means it will return when the first successful connection is established. Remaining pools are added asynchronously. """ + reject_late_connect = False + shutdown_rejected_cluster = False with self._lock: if self.is_shutdown: raise DriverException("Cluster is already shut down") @@ -1923,7 +1990,20 @@ def connect(self, keyspace=None, wait_for_all_pools=False): log.debug("Connecting to cluster, contact points: %s; protocol version: %s", self.contact_points, self.protocol_version) self.connection_class.initialize_reactor() - _register_cluster_shutdown(self) + + if not _register_cluster_shutdown(self): + # threading shutdown callbacks run before non-daemon + # application threads are joined. A thread can therefore + # reach connect() after scheduler cleanup. Existing clusters + # remain registered for ordinary atexit cleanup. A new cluster + # must claim shutdown here, but cleanup cannot wait for its + # executor while this call may itself be running on a worker. + reject_late_connect = True + if not self._is_setup: + self.is_shutdown = True + shutdown_rejected_cluster = True + + elif not self._is_setup: self._report_tls_session_resumption() try: @@ -1947,6 +2027,12 @@ def connect(self, keyspace=None, wait_for_all_pools=False): ) self._is_setup = True + if reject_late_connect: + if shutdown_rejected_cluster: + self._shutdown(wait_for_executor=False) + raise DriverException( + "Cannot connect a Cluster during interpreter shutdown") + session = self._new_session(keyspace) if wait_for_all_pools: wait_futures(session._initial_connect_futures) @@ -1999,6 +2085,14 @@ def shutdown(self): else: self.is_shutdown = True + # Once interpreter scheduler cleanup has begun, executor teardown is + # imminent and this call may itself be running on an executor worker. + self._shutdown( + wait_for_executor=not _cluster_scheduler_shutdown_started) + + def _shutdown(self, wait_for_executor): + """Finish cleanup after shutdown has been claimed under ``self._lock``.""" + if self._idle_heartbeat: self._idle_heartbeat.stop() @@ -2009,7 +2103,7 @@ def shutdown(self): for session in tuple(self.sessions): session.shutdown() - self.executor.shutdown() + self.executor.shutdown(wait=wait_for_executor) if self.metrics_enabled and self.metrics: self.metrics.shutdown() @@ -5387,6 +5481,9 @@ def __init__(self, executor): self._scheduled_tasks = set() self._count = count() self._executor = executor + self._lock = Lock() + self._shutdown_complete = Event() + self._shutdown_owner = None Thread.__init__(self, name="Task Scheduler") self.daemon = True @@ -5398,12 +5495,52 @@ def shutdown(self): except AttributeError: # this can happen on interpreter shutdown pass - self.is_shutdown = True - self._queue.put_nowait((0, 0, None)) - self.join() + shutdown_owner = get_ident() + with self._lock: + if self.is_shutdown: + if self._shutdown_complete.is_set() or \ + self._shutdown_owner == shutdown_owner: + return + wait_for_shutdown = True + else: + self.is_shutdown = True + self._shutdown_owner = shutdown_owner + wait_for_shutdown = False + + if wait_for_shutdown: + self._shutdown_complete.wait() + return + + try: + self._queue.put_nowait((0, 0, None, None)) + self.join() + self._drain_shutdown_callbacks() + finally: + self._shutdown_complete.set() + + def _drain_shutdown_callbacks(self): + shutdown_callbacks = [] + with self._lock: + while True: + try: + _, _, task, on_shutdown = self._queue.get_nowait() + except queue.Empty: + break + if task is not None: + self._scheduled_tasks.discard(task) + if on_shutdown is not None: + shutdown_callbacks.append(on_shutdown) + + for callback in shutdown_callbacks: + self._run_shutdown_callback(callback) def schedule(self, delay, fn, *args, **kwargs): - self._insert_task(delay, (fn, args, tuple(kwargs.items()))) + return self._insert_task(delay, (fn, args, tuple(kwargs.items()))) + + def schedule_with_shutdown(self, delay, on_shutdown, fn, *args, **kwargs): + """Schedule a task and notify it if shutdown prevents it from running.""" + return self._insert_task( + delay, (fn, args, tuple(kwargs.items())), on_shutdown=on_shutdown) def schedule_unique(self, delay, fn, *args, **kwargs): task = (fn, args, tuple(kwargs.items())) @@ -5412,13 +5549,34 @@ def schedule_unique(self, delay, fn, *args, **kwargs): else: log.debug("Ignoring schedule_unique for already-scheduled task: %r", task) - def _insert_task(self, delay, task): - if not self.is_shutdown: - run_at = time.time() + delay - self._scheduled_tasks.add(task) - self._queue.put_nowait((run_at, next(self._count), task)) - else: + def _insert_task(self, delay, task, on_shutdown=None): + with self._lock: + if not self.is_shutdown: + run_at = time.time() + delay + self._scheduled_tasks.add(task) + self._queue.put_nowait( + (run_at, next(self._count), task, on_shutdown)) + return True + + if on_shutdown is not None: + self._run_shutdown_callback(on_shutdown) + try: log.debug("Ignoring scheduled task after shutdown: %r", task) + except AttributeError: + # this can happen on interpreter shutdown + pass + return False + + @staticmethod + def _run_shutdown_callback(callback): + try: + callback() + except Exception: + try: + log.exception("Scheduled task shutdown callback failed") + except AttributeError: + # this can happen on interpreter shutdown + pass def run(self): while True: @@ -5427,19 +5585,33 @@ def run(self): try: while True: - run_at, i, task = self._queue.get(block=True, timeout=None) - if self.is_shutdown: - if task: - log.debug("Not executing scheduled task due to Scheduler shutdown") + run_at, i, task, on_shutdown = self._queue.get(block=True, timeout=None) + task_to_submit = None + stop = False + with self._lock: + if self.is_shutdown: + stop = True + if task is not None: + # Leave shutdown callbacks to shutdown(), after + # this thread has stopped. Running one here can + # deadlock if it re-enters scheduler shutdown + # while the shutdown owner is joining us. + self._queue.put_nowait( + (run_at, i, task, on_shutdown)) + elif run_at <= time.time(): + self._scheduled_tasks.discard(task) + task_to_submit = task + else: + self._queue.put_nowait((run_at, i, task, on_shutdown)) + + if stop: return - if run_at <= time.time(): - self._scheduled_tasks.discard(task) - fn, args, kwargs = task - kwargs = dict(kwargs) - future = self._executor.submit(fn, *args, **kwargs) + if task_to_submit is not None: + fn, args, kwargs = task_to_submit + future = self._executor.submit( + fn, *args, **dict(kwargs)) future.add_done_callback(self._log_if_failed) else: - self._queue.put_nowait((run_at, i, task)) break except queue.Empty: pass @@ -5567,6 +5739,8 @@ def __init__(self, session, message, query, timeout, metrics=None, prepared_stat self._host = host self._routing_token = routing_token self._control_connection_query_attempted = False + self._page_generation = 0 + self._retry_aborted = False self._spec_execution_plan = speculative_execution_plan or self._spec_execution_plan self._make_query_plan() self._event = Event() @@ -6207,12 +6381,21 @@ def start_fetching_next_page(self): if not self._paging_state: raise QueryExhausted() + with self._callback_lock: + if self._retry_aborted: + raise self._final_exception + self._make_query_plan() - self.message.paging_state = self._paging_state - self._event.clear() - self._final_result = _NOT_SET - self._final_exception = None - self._control_connection_query_attempted = False + + with self._callback_lock: + if self._retry_aborted: + raise self._final_exception + self._page_generation += 1 + self.message.paging_state = self._paging_state + self._event.clear() + self._final_result = _NOT_SET + self._final_exception = None + self._control_connection_query_attempted = False self._start_timer() self.send_request() @@ -6528,10 +6711,9 @@ def _execute_after_prepare(self, host, connection, pool, response): def _set_final_result(self, response): self._cancel_timer() - if self._metrics is not None: - self._metrics.request_timer.addValue(time.time() - self._start_time) - with self._callback_lock: + if self._retry_aborted: + return self._final_result = response # save off current callbacks inside lock for execution outside it # -- prevents case where _final_result is set, then a callback is @@ -6542,18 +6724,29 @@ def _set_final_result(self, response): for (fn, args, kwargs) in self._callbacks ) + if self._metrics is not None: + self._metrics.request_timer.addValue(time.time() - self._start_time) self._event.set() # apply each callback for callback_partial in to_call: callback_partial() - def _set_final_exception(self, response): - self._cancel_timer() - if self._metrics is not None: - self._metrics.request_timer.addValue(time.time() - self._start_time) + def _set_final_exception(self, response, + retry_abort_generation=_NOT_SET): + abort_retry = retry_abort_generation is not _NOT_SET + if not abort_retry: + self._cancel_timer() with self._callback_lock: + if self._retry_aborted: + return + if abort_retry: + if retry_abort_generation != self._page_generation or \ + self._final_result is not _NOT_SET or \ + self._final_exception is not None: + return + self._retry_aborted = True self._final_exception = response # save off current errbacks inside lock for execution outside it -- # prevents case where _final_exception is set, then an errback is @@ -6563,6 +6756,11 @@ def _set_final_exception(self, response): partial(fn, response, *args, **kwargs) for (fn, args, kwargs) in self._errbacks ) + + if abort_retry: + self._cancel_timer() + if self._metrics is not None: + self._metrics.request_timer.addValue(time.time() - self._start_time) self._event.set() # apply each callback @@ -6596,10 +6794,12 @@ def exception_from_response(response): self._errors[host] = exception_from_response(response) def _retry(self, reuse_connection, consistency_level, host, delay): - if self._final_exception: - # the connection probably broke while we were waiting - # to retry the operation - return + with self._callback_lock: + if self._final_exception: + # the connection probably broke while we were waiting + # to retry the operation + return + page_generation = self._page_generation if self._metrics is not None: self._metrics.on_retry() @@ -6617,7 +6817,14 @@ def _retry(self, reuse_connection, consistency_level, host, delay): self.message.consistency_level = consistency_level # don't retry on the event loop thread - self.session.cluster.scheduler.schedule(delay, self._retry_task, reuse_connection, host) + self.session.cluster.scheduler.schedule_with_shutdown( + delay, partial(self._abort_retry, page_generation), + self._retry_task, reuse_connection, host) + + def _abort_retry(self, page_generation): + self._set_final_exception(ConnectionShutdown( + "Cluster scheduler was shut down before the retry could run"), + retry_abort_generation=page_generation) def _retry_task(self, reuse_connection, host): if self._final_exception: diff --git a/tests/unit/test_cluster.py b/tests/unit/test_cluster.py index 9f6dfb5403..3c092820de 100644 --- a/tests/unit/test_cluster.py +++ b/tests/unit/test_cluster.py @@ -16,11 +16,18 @@ from concurrent.futures import Future import gc import logging +import multiprocessing +from pathlib import Path +from queue import PriorityQueue import socket import ssl +import subprocess +import sys +import tempfile import threading import time import weakref +from threading import Event, Lock, Thread from types import SimpleNamespace from unittest.mock import patch, Mock @@ -44,6 +51,26 @@ log = logging.getLogger(__name__) + +def _run_shutdown_subprocess(script): + """Run shutdown code without letting the source tree shadow an installed wheel.""" + driver_path = str(Path(__file__).parents[2]) + script = "import sys\nsys.path.append({!r})\n".format(driver_path) + script + with tempfile.TemporaryDirectory() as temp_dir: + result = subprocess.run( + [sys.executable, '-c', script], + capture_output=True, + text=True, + timeout=5, + cwd=temp_dir, + ) + + assert result.returncode == 0, ( + "Subprocess failed\nstdout:\n{}\nstderr:\n{}".format( + result.stdout, result.stderr)) + return result + + class ExceptionTypeTest(unittest.TestCase): def test_exception_types(self): @@ -857,6 +884,576 @@ def test_failed_down_submission_releases_pending_state(self): class SchedulerTest(unittest.TestCase): # TODO: this suite could be expanded; for now just adding a test covering a ticket + @pytest.mark.skipif( + 'fork' not in multiprocessing.get_all_start_methods(), + reason='requires the multiprocessing fork start method') + def test_fork_child_ignores_parent_scheduler_registry(self): + """A fork child must not run callbacks or locks inherited from its parent.""" + script = ''' +import multiprocessing +import os +from threading import Event, Thread + +from cassandra.cluster import ( + _clusters_for_shutdown_lock, + _discard_cluster_shutdown, + _register_cluster_shutdown, +) + + +class Scheduler: + + def __init__(self, owner): + self.owner = owner + + def shutdown(self): + print(self.owner + ' scheduler cleanup pid: ' + str(os.getpid()), flush=True) + + +class RegisteredCluster: + + def __init__(self, owner): + self.scheduler = Scheduler(owner) + + def shutdown(self): + pass + + +def child_target(): + _register_cluster_shutdown(RegisteredCluster('child')) + + +parent_cluster = RegisteredCluster('parent') +_register_cluster_shutdown(parent_cluster) +lock_acquired = Event() +release_lock = Event() + + +def hold_registry_lock(): + with _clusters_for_shutdown_lock: + lock_acquired.set() + release_lock.wait() + + +holder = Thread(target=hold_registry_lock) +holder.start() +lock_acquired.wait() +child = multiprocessing.get_context('fork').Process(target=child_target) +try: + child.start() + child.join(1) + print('child exited:', not child.is_alive()) + print('child exitcode:', child.exitcode) + if child.is_alive(): + child.terminate() + child.join() +finally: + release_lock.set() + holder.join() + _discard_cluster_shutdown(parent_cluster) +''' + + result = _run_shutdown_subprocess(script) + + assert 'child exited: True' in result.stdout + assert 'child exitcode: 0' in result.stdout + assert 'child scheduler cleanup pid:' in result.stdout + assert 'parent scheduler cleanup pid:' not in result.stdout + + def test_scheduler_cleanup_precedes_executor_shutdown(self): + """Scheduler cleanup must precede executor shutdown.""" + script = ''' +import time + +from cassandra.cluster import Cluster, _register_cluster_shutdown + + +cluster = Cluster() +_register_cluster_shutdown(cluster) +original_shutdown = cluster.scheduler.shutdown + + +def marked_shutdown(): + print('scheduler cleanup ran') + original_shutdown() + + +cluster.scheduler.shutdown = marked_shutdown +cluster.executor.submit(time.sleep, 0.5) +cluster.scheduler.schedule(0.1, lambda: None) +''' + + result = _run_shutdown_subprocess(script) + + assert 'scheduler cleanup ran' in result.stdout + assert 'Exception in thread Task Scheduler' not in result.stderr + assert 'cannot schedule new futures' not in result.stderr + + def test_cluster_connect_after_scheduler_cleanup_is_rejected(self): + """A late cluster must not survive into executor shutdown.""" + script = ''' +from cassandra import DriverException +from cassandra.cluster import ( + Cluster, + _shutdown_cluster_schedulers, +) +from threading import Event + + +cluster = Cluster() +ready = Event() +continue_registration = Event() + + +def connect_after_cleanup(): + ready.set() + continue_registration.wait() + try: + cluster.connect() + except DriverException: + print('late connect rejected') + + +future = cluster.executor.submit(connect_after_cleanup) +ready.wait() +_shutdown_cluster_schedulers() +continue_registration.set() +future.result(timeout=2) + +print('cluster is shutdown:', cluster.is_shutdown) +print('scheduler is shutdown:', cluster.scheduler.is_shutdown) +''' + + result = _run_shutdown_subprocess(script) + + assert 'late connect rejected' in result.stdout + assert 'cluster is shutdown: True' in result.stdout + assert 'scheduler is shutdown: True' in result.stdout + assert 'cannot schedule new futures' not in result.stderr + + def test_initialized_cluster_connect_after_scheduler_cleanup_is_rejected(self): + """An initialized cluster must not create a session after cleanup.""" + script = ''' +from cassandra import DriverException +from cassandra.cluster import ( + Cluster, + ControlConnectionQueryFallback, + _shutdown_cluster_schedulers, +) +from threading import Event +from unittest.mock import patch + + +cluster = Cluster( + idle_heartbeat_interval=0, + allow_control_connection_query_fallback= + ControlConnectionQueryFallback.SkipPoolCreation, +) +with patch.object(cluster.connection_class, 'initialize_reactor'), \ + patch.object(cluster.control_connection, 'connect'), \ + patch.object(cluster, '_populate_hosts'), \ + patch.object(cluster.profile_manager, 'check_supported'): + sessions = [cluster.connect()] +ready = Event() +continue_connect = Event() + + +def connect_after_cleanup(): + ready.set() + continue_connect.wait() + try: + sessions.append(cluster.connect()) + except DriverException as exc: + print('late connect rejected:', exc) + + +future = cluster.executor.submit(connect_after_cleanup) +ready.wait() +_shutdown_cluster_schedulers() +continue_connect.set() +future.result(timeout=2) + +print('session count:', len(sessions)) +print('initial session is shutdown:', sessions[0].is_shutdown) +print('cluster is shutdown:', cluster.is_shutdown) +print('scheduler is shutdown:', cluster.scheduler.is_shutdown) +''' + + result = _run_shutdown_subprocess(script) + + assert ('late connect rejected: ' + 'Cannot connect a Cluster during interpreter shutdown' + in result.stdout) + assert 'session count: 1' in result.stdout + assert 'initial session is shutdown: False' in result.stdout + assert 'cluster is shutdown: False' in result.stdout + assert 'scheduler is shutdown: True' in result.stdout + assert 'cannot schedule new futures' not in result.stderr + + def test_late_connect_does_not_join_reentrant_executor_shutdown(self): + """Late rejection must not join a worker waiting to shut down.""" + script = ''' +import cassandra.cluster as cluster_module + +from cassandra import DriverException +from cassandra.cluster import ( + Cluster, + ControlConnectionQueryFallback, + _shutdown_cluster_schedulers, +) +from threading import Event +from unittest.mock import patch + + +cluster = Cluster( + idle_heartbeat_interval=0, + allow_control_connection_query_fallback= + ControlConnectionQueryFallback.SkipPoolCreation, +) +with patch.object(cluster.connection_class, 'initialize_reactor'), \ + patch.object(cluster.control_connection, 'connect'), \ + patch.object(cluster, '_populate_hosts'), \ + patch.object(cluster.profile_manager, 'check_supported'): + cluster.connect() + +release_worker = Event() +worker_shutting_down = Event() + + +def shutdown_from_executor(): + release_worker.wait() + worker_shutting_down.set() + cluster.shutdown() + print('executor shutdown completed') + + +future = cluster.executor.submit(shutdown_from_executor) +_shutdown_cluster_schedulers() +register_cluster = cluster_module._register_cluster_shutdown + + +def coordinate_reentrant_shutdown(current_cluster): + release_worker.set() + worker_shutting_down.wait() + return register_cluster(current_cluster) + + +with patch.object( + cluster_module, + '_register_cluster_shutdown', + side_effect=coordinate_reentrant_shutdown): + try: + cluster.connect() + except DriverException: + print('main connect rejected') + +future.result(timeout=2) +print('reentrant shutdown completed') +''' + + result = _run_shutdown_subprocess(script) + + assert 'main connect rejected' in result.stdout + assert 'executor shutdown completed' in result.stdout + assert 'reentrant shutdown completed' in result.stdout + + def test_cluster_shutdown_follows_application_thread_shutdown(self): + """Full cluster cleanup must wait for application threads.""" + script = ''' +import time +from concurrent.futures import ThreadPoolExecutor + +from cassandra.cluster import Cluster, _register_cluster_shutdown + + +cluster = Cluster() +_register_cluster_shutdown(cluster) + + +def run_query(): + time.sleep(0.1) + print('cluster is active:', not cluster.is_shutdown) + + +ThreadPoolExecutor(max_workers=1).submit(run_query) +''' + + result = _run_shutdown_subprocess(script) + + assert 'cluster is active: True' in result.stdout + + def test_scheduler_cleanup_fails_retry_blocking_application_thread(self): + """A pending retry must not strand a non-daemon thread at shutdown.""" + script = ''' +from types import SimpleNamespace +from threading import Thread + +from cassandra import ConsistencyLevel +from cassandra.cluster import Cluster, ResponseFuture, _register_cluster_shutdown +from cassandra.connection import ConnectionShutdown +from cassandra.protocol import QueryMessage +from cassandra.query import SimpleStatement + + +cluster = Cluster() +_register_cluster_shutdown(cluster) +query = SimpleStatement('SELECT * FROM system.local') +session = SimpleNamespace( + cluster=cluster, + keyspace=None, + row_factory=lambda columns, rows: rows, +) +response_future = ResponseFuture( + session, + QueryMessage(query.query_string, ConsistencyLevel.ONE), + query, + timeout=None, +) +response_future._retry(False, None, None, 60) + + +def wait_for_retry(): + try: + response_future.result() + except ConnectionShutdown: + print('pending retry failed') + + +Thread(target=wait_for_retry).start() +''' + + result = _run_shutdown_subprocess(script) + + assert 'pending retry failed' in result.stdout + assert 'cannot schedule new futures' not in result.stderr + + def test_task_scheduled_after_shutdown_is_rejected(self): + scheduler = _Scheduler(Mock()) + scheduler.shutdown() + on_shutdown = Mock() + + accepted = scheduler.schedule_with_shutdown( + 0, on_shutdown, lambda: None) + + assert not accepted + on_shutdown.assert_called_once_with() + + def test_concurrent_scheduler_shutdown_waits_for_callbacks(self): + scheduler = _Scheduler(Mock()) + callback_started = Event() + release_callback = Event() + second_shutdown_complete = Event() + + def on_shutdown(): + callback_started.set() + assert release_callback.wait(5) + + scheduler.schedule_with_shutdown(60, on_shutdown, lambda: None) + first_shutdown = Thread(target=scheduler.shutdown) + first_shutdown.start() + assert callback_started.wait(5) + + def run_second_shutdown(): + scheduler.shutdown() + second_shutdown_complete.set() + + second_shutdown = Thread(target=run_second_shutdown) + second_shutdown.start() + assert not second_shutdown_complete.wait(0.05) + + release_callback.set() + first_shutdown.join(5) + second_shutdown.join(5) + assert not first_shutdown.is_alive() + assert not second_shutdown.is_alive() + assert second_shutdown_complete.is_set() + + def test_scheduler_shutdown_callback_can_reenter_shutdown(self): + scheduler = _Scheduler(Mock()) + callback_complete = Event() + + def on_shutdown(): + scheduler.shutdown() + callback_complete.set() + + scheduler.schedule_with_shutdown(60, on_shutdown, lambda: None) + shutdown = Thread(target=scheduler.shutdown) + shutdown.start() + shutdown.join(5) + + assert not shutdown.is_alive() + assert callback_complete.is_set() + + def test_dequeued_shutdown_callback_can_reenter_shutdown(self): + task_dequeued = Event() + release_task = Event() + callback_complete = Event() + + class PausingQueue(object): + + def __init__(self): + self._queue = PriorityQueue() + + def get(self, *args, **kwargs): + item = self._queue.get(*args, **kwargs) + if item[2] is not None: + task_dequeued.set() + assert release_task.wait(5) + return item + + def get_nowait(self): + return self._queue.get_nowait() + + def put_nowait(self, item): + self._queue.put_nowait(item) + + task_queue = PausingQueue() + with patch('cassandra.cluster.queue.PriorityQueue', + return_value=task_queue): + scheduler = _Scheduler(Mock()) + + def on_shutdown(): + scheduler.shutdown() + callback_complete.set() + + scheduler.schedule_with_shutdown(0, on_shutdown, lambda: None) + assert task_dequeued.wait(5) + + shutdown = Thread(target=scheduler.shutdown) + shutdown.start() + deadline = time.time() + 5 + while not scheduler.is_shutdown and time.time() < deadline: + time.sleep(0.001) + assert scheduler.is_shutdown + release_task.set() + shutdown.join(5) + + assert not shutdown.is_alive() + assert callback_complete.is_set() + + @patch('cassandra.cluster.time') + def test_shutdown_sentinel_exits_without_scheduler_sleep(self, scheduler_time): + scheduler = object.__new__(_Scheduler) + scheduler.is_shutdown = False + scheduler._lock = Lock() + scheduler._scheduled_tasks = set() + scheduler._queue = Mock() + + def get_shutdown_sentinel(**kwargs): + scheduler.is_shutdown = True + return (0, 0, None, None) + + scheduler._queue.get.side_effect = get_shutdown_sentinel + + scheduler.run() + + scheduler_time.sleep.assert_not_called() + + def test_executor_submission_does_not_hold_scheduler_lock(self): + executor = Mock() + submit_started = Event() + release_submit = Event() + schedule_started = Event() + schedule_complete = Event() + submitted_future = Future() + submitted_future.set_result(None) + + def submit(*args, **kwargs): + submit_started.set() + assert release_submit.wait(5) + return submitted_future + + executor.submit.side_effect = submit + scheduler = _Scheduler(executor) + scheduler.schedule(0, lambda: None) + assert submit_started.wait(5) + + def schedule_later_task(): + schedule_started.set() + scheduler.schedule(60, lambda: None) + schedule_complete.set() + + schedule_thread = Thread(target=schedule_later_task) + schedule_thread.start() + try: + assert schedule_started.wait(5) + assert schedule_complete.wait(1) + finally: + release_submit.set() + schedule_thread.join(5) + scheduler.shutdown() + + assert not schedule_thread.is_alive() + + def test_scheduler_cleanup_error_does_not_abort_threading_shutdown(self): + """Scheduler failures must not prevent later shutdown callbacks.""" + script = ''' +import logging +import threading + + +logging.basicConfig(level=logging.ERROR) +threading._register_atexit(lambda: print('remaining callback ran')) + +from cassandra.cluster import Cluster, _register_cluster_shutdown + + +cluster = Cluster() +_register_cluster_shutdown(cluster) +original_shutdown = cluster.scheduler.shutdown +shutdown_calls = 0 + + +def fail_first_shutdown(): + global shutdown_calls + shutdown_calls += 1 + if shutdown_calls == 1: + raise RuntimeError('scheduler cleanup failed') + original_shutdown() + + +cluster.scheduler.shutdown = fail_first_shutdown +''' + + result = _run_shutdown_subprocess(script) + + assert 'remaining callback ran' in result.stdout + assert 'Failed to shut down Cluster scheduler' in result.stderr + + def test_late_import_after_concurrent_futures_rejects_cluster_connect(self): + """A warm late import must reject connects when cleanup cannot register.""" + script = ''' +import logging +import threading +# Load concurrent.futures.thread before shutdown. A genuinely cold import fails +# in the standard library before cassandra.cluster can handle registration. +from concurrent.futures import ThreadPoolExecutor +from cassandra import DriverException + + +logging.basicConfig(level=logging.WARNING) +threading._SHUTTING_DOWN = True +try: + from cassandra.cluster import Cluster +finally: + threading._SHUTTING_DOWN = False + +cluster = Cluster() +try: + cluster.connect() +except DriverException as exc: + print('late connect rejected:', exc) + +print('cluster is shutdown:', cluster.is_shutdown) +''' + + result = _run_shutdown_subprocess(script) + + assert 'Could not register Cluster scheduler shutdown' in result.stderr + assert ('late connect rejected: Cannot connect a Cluster during interpreter shutdown' + in result.stdout) + assert 'cluster is shutdown: True' in result.stdout + @patch('time.time', return_value=3) # always queue at same time @patch('cassandra.cluster._Scheduler.run') # don't actually run the thread def test_event_delay_timing(self, *_): diff --git a/tests/unit/test_response_future.py b/tests/unit/test_response_future.py index 1515143bd5..0a0053aa78 100644 --- a/tests/unit/test_response_future.py +++ b/tests/unit/test_response_future.py @@ -16,13 +16,14 @@ import unittest from collections import deque -from threading import Event, RLock, Thread +from threading import Barrier, Event, RLock, Thread from unittest.mock import Mock, MagicMock, ANY, patch from cassandra import ConsistencyLevel, InvalidRequest, Unavailable, SchemaTargetType, SchemaChangeType, OperationTimedOut from cassandra.cluster import (Session, ResponseFuture, NoHostAvailable, ProtocolVersion, ControlConnection, ControlConnectionQueryFallback, _NOT_SET) -from cassandra.connection import Connection, ConnectionBusy, ConnectionException +from cassandra.connection import (Connection, ConnectionBusy, ConnectionException, + ConnectionShutdown) from cassandra.datastax.graph import SimpleGraphStatement from cassandra.protocol import (ReadTimeoutErrorMessage, WriteTimeoutErrorMessage, UnavailableErrorMessage, ResultMessage, QueryMessage, @@ -298,7 +299,6 @@ def test_retry_policy_says_retry(self): retry_policy = Mock() retry_policy.on_unavailable.return_value = (RetryPolicy.RETRY, ConsistencyLevel.ONE) - rf = ResponseFuture(session, message, query, 1, retry_policy=retry_policy) rf.send_request() @@ -310,7 +310,8 @@ def test_retry_policy_says_retry(self): host = Mock() rf._set_result(host, None, None, result) - rf.session.cluster.scheduler.schedule.assert_called_once_with(ANY, rf._retry_task, True, host) + rf.session.cluster.scheduler.schedule_with_shutdown.assert_called_once_with( + ANY, ANY, rf._retry_task, True, host) assert 1 == rf._query_retries connection = Mock(spec=Connection) @@ -345,7 +346,8 @@ def test_retry_with_different_host(self): host = Mock() rf._set_result(host, None, None, result) - rf.session.cluster.scheduler.schedule.assert_called_once_with(ANY, rf._retry_task, False, host) + rf.session.cluster.scheduler.schedule_with_shutdown.assert_called_once_with( + ANY, ANY, rf._retry_task, False, host) # query_retries does get incremented for Overloaded/Bootstrapping errors (since 3.18) assert 1 == rf._query_retries @@ -377,7 +379,8 @@ def test_all_retries_fail(self): rf._set_result(host, None, None, result) # simulate the executor running this - rf.session.cluster.scheduler.schedule.assert_called_once_with(ANY, rf._retry_task, False, host) + rf.session.cluster.scheduler.schedule_with_shutdown.assert_called_once_with( + ANY, ANY, rf._retry_task, False, host) rf._retry_task(False, host) @@ -388,7 +391,8 @@ def test_all_retries_fail(self): rf._set_result(host, None, None, result) # simulate the executor running this - rf.session.cluster.scheduler.schedule.assert_called_with(ANY, rf._retry_task, False, host) + rf.session.cluster.scheduler.schedule_with_shutdown.assert_called_with( + ANY, ANY, rf._retry_task, False, host) rf._retry_task(False, host) with pytest.raises(NoHostAvailable): @@ -411,12 +415,172 @@ def test_exponential_retry_policy_fail(self): rf._set_result(host, None, None, result) # simulate the executor running this - rf.session.cluster.scheduler.schedule.assert_called_once_with(ANY, rf._retry_task, False, host) + rf.session.cluster.scheduler.schedule_with_shutdown.assert_called_once_with( + ANY, ANY, rf._retry_task, False, host) - delay = rf.session.cluster.scheduler.schedule.mock_calls[-1][1][0] + delay = rf.session.cluster.scheduler.schedule_with_shutdown.mock_calls[-1][1][0] assert delay > 0.05 rf._retry_task(False, host) + def test_retry_is_failed_when_scheduler_shuts_down(self): + session = self.make_session() + rf = self.make_response_future(session) + + rf._abort_retry(rf._page_generation) + + with pytest.raises(ConnectionShutdown, match="scheduler was shut down"): + rf.result() + + def test_retry_abort_does_not_replace_completed_result(self): + session = self.make_session() + rf = self.make_response_future(session) + result = object() + callback = Mock() + errback = Mock() + rf.add_callbacks(callback, errback) + + rf._set_final_result(result) + rf._abort_retry(rf._page_generation) + + assert rf._final_result is result + assert rf._final_exception is None + callback.assert_called_once_with(result) + errback.assert_not_called() + + def test_result_does_not_replace_completed_retry_abort(self): + session = self.make_session() + rf = self.make_response_future(session) + callback = Mock() + errback = Mock() + rf.add_callbacks(callback, errback) + + rf._abort_retry(rf._page_generation) + rf._set_final_result(object()) + + with pytest.raises(ConnectionShutdown, match="scheduler was shut down"): + rf.result() + callback.assert_not_called() + errback.assert_called_once() + + def test_retry_abort_does_not_replace_completed_exception(self): + session = self.make_session() + rf = self.make_response_future(session) + error = RuntimeError('request failed') + errback = Mock() + rf.add_errback(errback) + + rf._set_final_exception(error) + rf._abort_retry(rf._page_generation) + + with pytest.raises(RuntimeError, match='request failed'): + rf.result() + errback.assert_called_once_with(error) + + def test_exception_does_not_replace_completed_retry_abort(self): + session = self.make_session() + rf = self.make_response_future(session) + errback = Mock() + rf.add_errback(errback) + + rf._abort_retry(rf._page_generation) + rf._set_final_exception(RuntimeError('late failure')) + rf._abort_retry(rf._page_generation) + + with pytest.raises(ConnectionShutdown, match='scheduler was shut down'): + rf.result() + errback.assert_called_once() + + def test_retry_abort_race_has_one_completion(self): + session = self.make_session() + rf = self.make_response_future(session) + result = object() + callback = Mock() + errback = Mock() + rf.add_callbacks(callback, errback) + barrier = Barrier(3) + + def set_result(): + barrier.wait() + rf._set_final_result(result) + + def abort_retry(): + barrier.wait() + rf._abort_retry(rf._page_generation) + + threads = [Thread(target=set_result), Thread(target=abort_retry)] + for thread in threads: + thread.start() + barrier.wait() + for thread in threads: + thread.join(5) + assert not thread.is_alive() + + assert rf._event.is_set() + assert callback.call_count + errback.call_count == 1 + if rf._retry_aborted: + assert rf._final_result is _NOT_SET + assert isinstance(rf._final_exception, ConnectionShutdown) + else: + assert rf._final_result is result + assert rf._final_exception is None + + def test_stale_retry_abort_does_not_abort_next_page(self): + session = self.make_session() + rf = self.make_response_future(session) + rf._retry(False, None, Mock(), 0) + on_shutdown = session.cluster.scheduler.schedule_with_shutdown.call_args.args[1] + first_result = object() + rf._paging_state = b'next-page' + rf._set_final_result(first_result) + rf.send_request = Mock() + + rf.start_fetching_next_page() + current_timer = Mock() + rf._timer = current_timer + on_shutdown() + + assert not rf._event.is_set() + assert rf._final_result is _NOT_SET + assert rf._final_exception is None + assert not rf._retry_aborted + current_timer.cancel.assert_not_called() + + second_result = object() + rf._set_final_result(second_result) + assert rf._final_result is second_result + + def test_next_page_after_retry_abort_fails_immediately(self): + session = self.make_session() + rf = self.make_response_future(session) + rf._paging_state = b'next-page' + rf._make_query_plan = Mock() + rf.send_request = Mock() + rf._abort_retry(rf._page_generation) + + with pytest.raises(ConnectionShutdown, match='scheduler was shut down'): + rf.start_fetching_next_page() + + assert rf._event.is_set() + assert rf._retry_aborted + rf._make_query_plan.assert_not_called() + rf.send_request.assert_not_called() + + def test_next_page_query_plan_failure_preserves_completed_page(self): + session = self.make_session() + rf = self.make_response_future(session) + result = object() + rf._paging_state = b'next-page' + rf._set_final_result(result) + rf._make_query_plan = Mock(side_effect=RuntimeError('plan failed')) + + with pytest.raises(RuntimeError, match='plan failed'): + rf.start_fetching_next_page() + + assert rf._event.is_set() + assert rf._final_result is result + assert rf._final_exception is None + assert rf._page_generation == 0 + def test_all_pools_shutdown(self): session = self.make_basic_session() session.cluster._default_load_balancing_policy.make_query_plan.return_value = ['ip1', 'ip2'] @@ -1068,7 +1232,8 @@ def test_control_connection_fallback_retries_after_server_error(self): first_response.to_exception.return_value = first_response connection.send_msg.call_args[1]['cb'](first_response) - rf.session.cluster.scheduler.schedule.assert_called_once_with(ANY, rf._retry_task, False, control_host) + rf.session.cluster.scheduler.schedule_with_shutdown.assert_called_once_with( + ANY, ANY, rf._retry_task, False, control_host) # The retry decision must come from the future state, not the live connection reference. rf._connection = Mock(is_control_connection=False) @@ -1848,7 +2013,8 @@ def test_single_host_query_plan_exhausted_after_one_retry(self): rf._set_result(specific_host, None, None, result) # The retry should be scheduled - rf.session.cluster.scheduler.schedule.assert_called_once_with(ANY, rf._retry_task, False, specific_host) + rf.session.cluster.scheduler.schedule_with_shutdown.assert_called_once_with( + ANY, ANY, rf._retry_task, False, specific_host) assert 1 == rf._query_retries # Reset mocks to track next calls From 5eac9658cad11b04d56c52eacfef5be5ee39d833 Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Fri, 25 Sep 2026 09:44:35 -0400 Subject: [PATCH 195/200] docs: update dependencies to address security alerts (#1042) Refresh the docs lock with patched anyio, soupsieve, tornado, and aiohttp releases. This clears the docs Dependabot alerts and removes related aiohttp vulnerabilities from the docs environment. --- docs/uv.lock | 129 ++++++++++++++++++++++++++------------------------- 1 file changed, 65 insertions(+), 64 deletions(-) diff --git a/docs/uv.lock b/docs/uv.lock index e03c2e51dd..6d79caaeb0 100644 --- a/docs/uv.lock +++ b/docs/uv.lock @@ -22,7 +22,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.14.1" +version = "3.14.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -33,49 +33,49 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, - { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, - { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, - { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, - { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, - { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, - { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, - { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, - { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, - { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, - { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, - { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, - { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, - { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, - { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, - { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, - { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, - { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, - { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, - { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, - { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, - { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, - { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, - { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, - { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, - { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, - { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, - { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, - { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, - { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, - { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, - { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, - { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, - { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, - { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, + { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, + { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, + { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, + { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, + { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, + { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, + { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, + { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, + { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, ] [[package]] @@ -110,14 +110,15 @@ wheels = [ [[package]] name = "anyio" -version = "4.14.2" +version = "4.15.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, ] [[package]] @@ -734,11 +735,11 @@ wheels = [ [[package]] name = "soupsieve" -version = "2.8.4" +version = "2.10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } +sdist = { url = "https://files.pythonhosted.org/packages/71/c3/1b817965ac12dc002d7c9cd7dfffdd7d4fbf9b45763ef2ffe7b86ee94670/soupsieve-2.10.tar.gz", hash = "sha256:49e9380d7d2905463583bafe285e818c7366a9ed7b3aee221c1ac79c905d8bc0", size = 124337, upload-time = "2026-09-24T02:36:35.048Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, + { url = "https://files.pythonhosted.org/packages/66/87/5ed59e1d0290564e2027ed066c52b059892c4637741337fc0967183c8d4d/soupsieve-2.10-py3-none-any.whl", hash = "sha256:8596eb8967d744174820280fa62b4542a2e955bfaccca73ed8a13c6eb8e9b502", size = 38019, upload-time = "2026-09-24T02:36:33.709Z" }, ] [[package]] @@ -1035,19 +1036,19 @@ wheels = [ [[package]] name = "tornado" -version = "6.5.7" +version = "6.5.10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/64/24/95ec527ad67b76d59299e5465b3935d05e4294b7e0290a3924b7487df30b/tornado-6.5.7.tar.gz", hash = "sha256:66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c3ddfc2", size = 519252, upload-time = "2026-06-08T17:34:51.232Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/61/53d562a57b28c08eda40b258c0f975e360541943ad7c7bef897a40caafda/tornado-6.5.10.tar.gz", hash = "sha256:a6b1ccd08c04b4a06fb5aeb381be99de5ad1e5375c1785e31d78c880feb57687", size = 537910, upload-time = "2026-09-15T13:47:48.73Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/dc/c7043cab6fed8ae159fc1923ce829ada35c4dbd797d408a43858ffaf9639/tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163", size = 448543, upload-time = "2026-06-08T17:34:38.052Z" }, - { url = "https://files.pythonhosted.org/packages/92/4f/090b1431e5a43df696feceffc268c5383cc079ecb5f08ce58f917109aafe/tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100", size = 446707, upload-time = "2026-06-08T17:34:39.594Z" }, - { url = "https://files.pythonhosted.org/packages/37/d8/ef374952fd5da67d4463122c2b8e5a96536ec10b4b339254c6dcde81d01c/tornado-6.5.7-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d759e71906ee783f8867b93bf26a265743da4c1e2f4a018464c1ba019862972", size = 449774, upload-time = "2026-06-08T17:34:41.204Z" }, - { url = "https://files.pythonhosted.org/packages/35/37/d434c73f4c6e014b745b9b37085f34f40c022f007efff3d7fe65991899f3/tornado-6.5.7-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a46347a18f23fb92b396beebe0fb78f61dda0cc302445202c16203d8a18848b", size = 450745, upload-time = "2026-06-08T17:34:42.531Z" }, - { url = "https://files.pythonhosted.org/packages/b6/2b/56b9aff361d7f1ab728a805ec7d7ea835f8807afa9f5cc690ea0e630efb9/tornado-6.5.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7778b30bef919231265e91c69963ce0f49a1e9c07ac900bbe75b19ce2575ba92", size = 450578, upload-time = "2026-06-08T17:34:43.787Z" }, - { url = "https://files.pythonhosted.org/packages/02/30/a7444fb23aa76860a14198fab96ac79f1866b0a6e19e26c4381b0938e50f/tornado-6.5.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e726f0c75da7726eec023aa62751ff8878bd2737e34fbdd33b1ae5897d2200f5", size = 449985, upload-time = "2026-06-08T17:34:45.326Z" }, - { url = "https://files.pythonhosted.org/packages/5c/42/5f0e56c01e8d9d36f4e23f367b85ae6cae0c1ecddd5e6977d8388ad27488/tornado-6.5.7-cp39-abi3-win32.whl", hash = "sha256:f8de3bf12d3efdd0cbe7c8887868198f8a91415e3f29fcf258d9b8eb7b1d9ae4", size = 451047, upload-time = "2026-06-08T17:34:46.784Z" }, - { url = "https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl", hash = "sha256:de942f843533a039ef9fa3d9c88c7cd8a7c94553fb5ad0154270989b3d99a2c4", size = 451485, upload-time = "2026-06-08T17:34:48.248Z" }, - { url = "https://files.pythonhosted.org/packages/71/2e/7b1c769803121b809112cf9a00681c472eae1d80e32d7ec0e0bd61d0d0e1/tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796", size = 450506, upload-time = "2026-06-08T17:34:49.702Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5b/ff5fc58fa2427c30dea74c90053f4fc5eda1e7f3833ed3ecc7147fe2b311/tornado-6.5.10-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9261783640e23258694a9ff0795df430a5a7b0a651d3dd53dd0969ad6be16da7", size = 465883, upload-time = "2026-09-15T13:47:35.463Z" }, + { url = "https://files.pythonhosted.org/packages/ad/f5/cd7be26c34a3315532f3aef5f092465da8f59c334dd439d3c14aaef16461/tornado-6.5.10-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:83e6cf438b106c6b3852d70960967bb1b70c87438050dca0981e4b9aa751a4c1", size = 464046, upload-time = "2026-09-15T13:47:37.178Z" }, + { url = "https://files.pythonhosted.org/packages/60/33/df6d7d04854a58619f8349a51e3edb138324130a7562b0bb21f115bb940f/tornado-6.5.10-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bdf942448169e5336451d0494d7e3d81cfa726d5aa312affdc4682dd62a62f6d", size = 467096, upload-time = "2026-09-15T13:47:38.559Z" }, + { url = "https://files.pythonhosted.org/packages/29/17/cc35dff68272d685cffd8600ffafbd8067e7d05e7348d9f80caddffbbd5f/tornado-6.5.10-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69acca6501eed74582b76dbbceee2a91613f54728e3e418346000d7103101676", size = 468067, upload-time = "2026-09-15T13:47:40.085Z" }, + { url = "https://files.pythonhosted.org/packages/c3/01/6e5349b4e1a53a4b4972a6716785e1fe7407f312063c3972690af8ff301b/tornado-6.5.10-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:66aaa3f57d30c6e6becee83ff28055d5930ac724214bde99393eefda83d5e015", size = 467901, upload-time = "2026-09-15T13:47:41.576Z" }, + { url = "https://files.pythonhosted.org/packages/28/5e/b4facf94370dba006819c8d304376f8b9fbec6b935b5e51bf45823a9790b/tornado-6.5.10-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4bd192b959f9128fb99b8898148070ba4574c9589b78bce42d1851131fe85828", size = 467308, upload-time = "2026-09-15T13:47:43.145Z" }, + { url = "https://files.pythonhosted.org/packages/56/ae/047938e828cafc8eca4c908fafb6588fee944e3af39a0af9d7b602499ae5/tornado-6.5.10-cp39-abi3-win32.whl", hash = "sha256:302eb1e0e3e159314eb591920529fdea80acca92df5510a2cec5bbd4f099ec72", size = 468387, upload-time = "2026-09-15T13:47:44.556Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d4/5901517f05affd752490f6a654ba31b7474664e8dd80bd045a00c220bd88/tornado-6.5.10-cp39-abi3-win_amd64.whl", hash = "sha256:37ae8f150cecfdbf747fc4e12f5e9a97ecd8cf1d4cdb3f119e2de84b11196918", size = 468828, upload-time = "2026-09-15T13:47:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/fd497f3a7f7b74bb04f4b94536b5c9f80742b5d50501fd27977652ddec16/tornado-6.5.10-cp39-abi3-win_arm64.whl", hash = "sha256:ce045d3c298fddd30e89a2777f97039d1b641eb9518ac7b26a4721903539c694", size = 467847, upload-time = "2026-09-15T13:47:47.283Z" }, ] [[package]] @@ -1076,11 +1077,11 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] [[package]] From f82c915c5d74e49cca1282f21e95a463087f8a84 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:12:57 -0400 Subject: [PATCH 196/200] Update actions/cache action to v6.1.0 (#1047) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/coverage.yml | 2 +- .github/workflows/integration-tests.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index b1073476fe..647813cccf 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -66,7 +66,7 @@ jobs: run: uv sync - name: Cache Scylla download - uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.ccm/repository key: scylla-${{ env.SCYLLA_VERSION }}-${{ runner.os }} diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 3908cf7e0d..90a4eef10f 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -82,7 +82,7 @@ jobs: run: uv sync - name: Cache Scylla download - uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.ccm/repository key: scylla-${{ env.SCYLLA_VERSION }}-${{ runner.os }} From 022079b2f73373606dd34e37287e148e55e39452 Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Fri, 25 Sep 2026 16:37:41 -0400 Subject: [PATCH 197/200] docs: fix pages workflow badge (#1048) --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 3dc2269866..ec7d7d06c9 100644 --- a/README.rst +++ b/README.rst @@ -14,8 +14,8 @@ Scylla Enterprise (2018.1.x+) using exclusively Cassandra's binary protocol and .. image:: https://github.com/scylladb/python-driver/actions/workflows/build-push.yml/badge.svg?branch=master :target: https://github.com/scylladb/python-driver/actions/workflows/build-push.yml?query=event%3Apush+branch%3Amaster -.. image:: https://github.com/scylladb/python-driver/actions/workflows/docs-pages.yaml/badge.svg?branch=master - :target: https://github.com/scylladb/python-driver/actions/workflows/docs-pages.yaml?query=event%3Apush+branch%3Amaster +.. image:: https://github.com/scylladb/python-driver/actions/workflows/docs-pages.yml/badge.svg?branch=master + :target: https://github.com/scylladb/python-driver/actions/workflows/docs-pages.yml?query=event%3Apush+branch%3Amaster .. image:: https://github.com/scylladb/python-driver/actions/workflows/integration-tests.yml/badge.svg?branch=master :target: https://github.com/scylladb/python-driver/actions/workflows/integration-tests.yml?query=event%3Apush+branch%3Amaster From 487d5951c5b0e10786f7d7981266d48d7f3ff223 Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Sat, 26 Sep 2026 14:57:08 -0400 Subject: [PATCH 198/200] Release 3.29.12: changelog, version and documentation --- CHANGELOG.rst | 86 ++++++++++++++++++++++++++++--------------- cassandra/__init__.py | 2 +- cassandra/policies.py | 4 +- docs/conf.py | 4 +- docs/installation.rst | 2 +- 5 files changed, 62 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 1b1c2c4132..5a2abd21b4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,5 +1,6 @@ -Unreleased -========== +3.29.12 +======== +Sep 26, 2026 Features -------- @@ -27,7 +28,37 @@ Features applied to every connection the cluster opens rather than only to the first one. * Negotiate and implement the ``SCYLLA_USE_METADATA_ID`` protocol extension: prepared statements skip re-sending result metadata on EXECUTE, and the driver automatically - refreshes cached metadata when the server detects a schema change (DRIVER-153) + refreshes cached metadata when the server detects a schema change (DRIVER-153, #770). +* Negotiate ``TABLETS_ROUTING_V2``, track tablet versions, and prefer tablet leaders for + strongly consistent tables (#913). +* Resume TLS sessions through the new per-endpoint ``SSLSessionCache``, with cache + lifetime and security controls exposed by ``Cluster`` (#789, DRIVER-165). +* Use a Cython LZ4 fast path linked directly to the C library for CQL v4 compression + (#809). + +Compatibility Changes +--------------------- +* Python 3.10 or newer is now required (#1043). Python 3.15 is supported, including + published CPython 3.15 wheels; free-threaded 3.15t is tested but does not receive + wheels (#1005). Wheels are no longer published for 32-bit Windows (#916). +* The eventlet, gevent, and Twisted reactors and their public modules have been removed + (#969, CASSPYTHON-13). +* The Insights package and its ``Cluster`` configuration API have been removed + (#968, CASSPYTHON-24). +* ``DCAwareRoundRobinPolicy.local_dc`` is now read-only. It is set by the constructor, + and filled in by the policy itself when the constructor was given none, from the first + host to come up. Code that assigned it should pass ``local_dc`` to the constructor + instead. +* ``PreparedStatement.result_metadata`` and ``PreparedStatement.result_metadata_id`` are + now read-only. They are replaced together by + ``PreparedStatement.update_result_metadata()``, so a request cannot observe metadata + and its id from different schema versions. Code that assigned either attribute + directly must call ``update_result_metadata()`` instead. +* Message serialization now receives the connection's negotiated ``ProtocolFeatures``. + Custom protocol handlers that override ``encode_message`` must accept a required + ``protocol_features`` keyword argument, and custom encoders that delegate to + ``msg.send_body`` must forward it. This enables the connection-specific serialization + required by ``SCYLLA_USE_METADATA_ID`` and ``TABLETS_ROUTING_V2``. Bug Fixes --------- @@ -98,15 +129,31 @@ Bug Fixes which is right for the host handler that only uses it to probe the host, but left the control connection dead the moment its backoff finally succeeded -- until a heartbeat noticed, or forever with ``idle_heartbeat_interval=0``. +* Preserve SERIAL and LOCAL_SERIAL semantics during routing and retries: LWT replicas + are no longer shuffled, and retries cannot downgrade to a non-serial consistency + level (#887). +* The default constant and exponential reconnection schedules no longer stop after 64 + attempts, and heartbeat failures report the configured timeout correctly (#834). +* Treat an explicit empty ``ssl_options`` mapping as SSL configuration when selecting a + shard-aware endpoint (#936). +* Invalidate cached tablet metadata when a table is deleted through a schema event + (#975). +* Stop schedulers before executor teardown and fail pending retry work cleanly during + interpreter shutdown (#392). +* Preserve Unix-socket control endpoints for local hosts (#944), and restore local + listen and broadcast metadata when token metadata is disabled (#1012). +* Mark compatibility MD5 operations as non-security uses so they work in FIPS-enabled + environments (#970). +* Use portable, precision-preserving shard-id arithmetic in the Cython implementation, + fixing MSVC builds and matching the Python implementation (#950). +* Report errors from every pool when ``Session.set_keyspace()`` fails, rather than only + the final error (#911), and validate the scope passed to + ``Session.wait_for_schema_agreement()`` (#917). +* Reject secure-connect-bundle archive paths that escape their extraction directory + (#969). Others ------ -* ``DCAwareRoundRobinPolicy.local_dc`` is now read-only. It is set by the constructor, - and filled in by the policy itself when the constructor was given none, from the first - host to come up. Assigning it afterwards was indistinguishable from that inference, - and the two mean different things: a datacenter the application chose against one the - driver guessed. Code that assigned it should pass ``local_dc`` to the constructor - instead. * ``Connection.max_request_id`` and ``Connection.orphaned_threshold`` now follow the ``max_in_flight`` actually in force. Both were computed in the class body, which runs once, so a subclass that set its own ``max_in_flight`` inherited values derived from the @@ -128,27 +175,6 @@ Others come through unchanged. Previously ``DRIVER_NAME`` and ``DRIVER_VERSION`` could be overridden, which misreported the driver to the server for the life of the connection and, in the clients table, to the operator reading the row. -* ``PreparedStatement.result_metadata`` and ``PreparedStatement.result_metadata_id`` are - now read-only. They are replaced together by - ``PreparedStatement.update_result_metadata()``, so a request can never observe a metadata - id paired with result metadata from a different schema version. Code that assigned either - attribute directly must call ``update_result_metadata()`` instead. -* Message serialization now receives the connection's negotiated ``ProtocolFeatures``: - ``Connection.send_msg`` passes ``protocol_features`` to the encoder, and - ``_ProtocolHandler.encode_message`` forwards it to each message's ``send_body``. - This changes the contracted signature of ``encode_message`` (and of ``send_body``). - Custom protocol handlers that override ``encode_message`` must accept a required - ``protocol_features`` keyword argument (adding ``**kwargs`` is recommended for - future-proofing), and custom encoders that delegate to ``msg.send_body`` should - forward it. There is deliberately no compatibility fallback: protocol extensions - are negotiated per connection at STARTUP, so an encoder unaware of - ``protocol_features`` could silently omit fields a negotiated extension requires. - This release emits no new bytes on the wire; the parameter is groundwork for - upcoming protocol extensions (``SCYLLA_USE_METADATA_ID``, ``TABLETS_ROUTING_V2``). -* Python 3.15 is now supported: wheels are published for it (cibuildwheel builds - ``cp315`` since 4.2.0, against a release candidate that is ABI compatible with the - final release) and the integration tests run on 3.15 and on free-threaded 3.15t. - As for 3.14, no free-threaded wheels are published; ``3.15t`` is tested only. 3.29.11 ======= diff --git a/cassandra/__init__.py b/cassandra/__init__.py index cb3703d40a..1502dbb557 100644 --- a/cassandra/__init__.py +++ b/cassandra/__init__.py @@ -23,7 +23,7 @@ def emit(self, record): logging.getLogger('cassandra').addHandler(NullHandler()) -__version_info__ = (3, 29, 11) +__version_info__ = (3, 29, 12) __version__ = '.'.join(map(str, __version_info__)) diff --git a/cassandra/policies.py b/cassandra/policies.py index ee24b4b5a8..159876b8bf 100644 --- a/cassandra/policies.py +++ b/cassandra/policies.py @@ -893,7 +893,7 @@ def __init__(self, delay, max_attempts=None): `max_attempts` should be a total number of attempts to be made before giving up, or :const:`None` to continue reconnection attempts forever. - The default is 64. + By default, reconnection attempts continue indefinitely. """ if delay < 0: raise ValueError("delay must not be negative") @@ -927,7 +927,7 @@ def __init__(self, base_delay, max_delay, max_attempts=None): `max_attempts` should be a total number of attempts to be made before giving up, or :const:`None` to continue reconnection attempts forever. - The default is 64. + By default, reconnection attempts continue indefinitely. """ if base_delay < 0 or max_delay < 0: raise ValueError("Delays may not be negative") diff --git a/docs/conf.py b/docs/conf.py index b43d2ca948..d78cf5d173 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -29,11 +29,11 @@ '3.29.6-scylla', '3.29.7-scylla', '3.29.8-scylla', - '3.29.11-scylla', + '3.29.12-scylla', ] BRANCHES = ['master'] # Set the latest version. -LATEST_VERSION = '3.29.11-scylla' +LATEST_VERSION = '3.29.12-scylla' # Set which versions are not released yet. UNSTABLE_VERSIONS = ['master'] # Set which versions are deprecated diff --git a/docs/installation.rst b/docs/installation.rst index 0e5be73ea8..9e232eb8d1 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -26,7 +26,7 @@ To check if the installation was successful, you can run:: python -c 'import cassandra; print(cassandra.__version__)' -It should print something like "3.29.11". +It should print something like "3.29.12". (*Optional*) Compression Support -------------------------------- From afe42033c574a23da6d2f321c1d4f2ff68465767 Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Fri, 3 Apr 2026 21:15:20 +0300 Subject: [PATCH 199/200] (improvement) cache namedtuple class in named_tuple_factory to avoid repeated exec() calls Cache the Row namedtuple class keyed on tuple(colnames) so Python's namedtuple() (which internally calls exec()) is only invoked once per unique column schema. For prepared statements the column names never change, eliminating redundant class creation on every result set. This applies to both the pure-Python and Cython builds: row decoding is done in Cython, but the row factory is still called per result. The cache is a functools.lru_cache (maxsize=10000) on a helper keyed on the raw column names, so differing case, order or contents produce distinct classes. lru_cache is thread-safe, including on free-threaded Python, and keeps memory bounded for applications running many ad hoc queries with varying schemas. named_tuple_factory itself stays a plain function, since ResultSet.was_applied compares against it by identity. The positional-name fallback for invalid identifiers is cached too, so its warning is logged once per schema. The SyntaxError fallback to pseudo_namedtuple_factory is removed: it only covered the 255-field namedtuple limit of Python 3.0-3.6, and requires-python is >=3.10. Cache correctness tests live in tests/unit/test_row_factories.py (run in CI); benchmarks/ contains only the timing benchmarks, and pytest-benchmark is added to the dev dependency group to run them. Signed-off-by: Yaniv Michael Kaul --- .../test_named_tuple_factory_benchmark.py | 171 ++++++++++++++++++ cassandra/query.py | 46 ++--- pyproject.toml | 1 + tests/unit/test_row_factories.py | 89 +++++++-- 4 files changed, 262 insertions(+), 45 deletions(-) create mode 100644 benchmarks/test_named_tuple_factory_benchmark.py diff --git a/benchmarks/test_named_tuple_factory_benchmark.py b/benchmarks/test_named_tuple_factory_benchmark.py new file mode 100644 index 0000000000..649b752229 --- /dev/null +++ b/benchmarks/test_named_tuple_factory_benchmark.py @@ -0,0 +1,171 @@ +# Copyright ScyllaDB, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Benchmarks for named_tuple_factory with and without namedtuple class caching. + +Run with: pytest benchmarks/test_named_tuple_factory_benchmark.py -v + +Correctness tests for the same cache (cache-hit, cache-key, and eviction +behavior) live in tests/unit/test_row_factories.py instead of here, because +the project's wheel test commands only run `pytest tests/unit` -- code left +only under benchmarks/ is never exercised in CI. Only pure timing/benchmark +code belongs in this file. +""" + +import re +from collections import namedtuple + +import pytest + +from cassandra.query import named_tuple_factory, _named_tuple_row_class +from cassandra.util import _sanitize_identifiers + + +# --------------------------------------------------------------------------- +# Reference: original uncached implementation (copied from master) +# --------------------------------------------------------------------------- + +NON_ALPHA_REGEX = re.compile("[^a-zA-Z0-9]") +START_BADCHAR_REGEX = re.compile("^[^a-zA-Z0-9]*") +END_BADCHAR_REGEX = re.compile("[^a-zA-Z0-9_]*$") + +_clean_name_cache_old = {} + + +def _clean_column_name_old(name): + try: + return _clean_name_cache_old[name] + except KeyError: + clean = NON_ALPHA_REGEX.sub( + "_", START_BADCHAR_REGEX.sub("", END_BADCHAR_REGEX.sub("", name)) + ) + _clean_name_cache_old[name] = clean + return clean + + +def named_tuple_factory_uncached(colnames, rows): + """Original implementation without caching (for benchmark comparison).""" + clean_column_names = map(_clean_column_name_old, colnames) + try: + Row = namedtuple("Row", clean_column_names) + except SyntaxError: + raise + except Exception: + clean_column_names = list(map(_clean_column_name_old, colnames)) + Row = namedtuple("Row", _sanitize_identifiers(clean_column_names)) + return [Row(*row) for row in rows] + + +# --------------------------------------------------------------------------- +# Test data generators +# --------------------------------------------------------------------------- + + +def make_colnames(n): + return tuple(f"col_{i}" for i in range(n)) + + +def make_rows(ncols, nrows): + return [tuple(range(ncols)) for _ in range(nrows)] + + +# --------------------------------------------------------------------------- +# Benchmarks +# --------------------------------------------------------------------------- + + +class TestNamedTupleFactoryBenchmark: + """Benchmark cached vs uncached named_tuple_factory.""" + + # --- 5 columns, 100 rows --- + + @pytest.mark.benchmark(group="ntf_5cols_100rows") + def test_uncached_5cols_100rows(self, benchmark): + colnames = make_colnames(5) + rows = make_rows(5, 100) + benchmark(named_tuple_factory_uncached, colnames, rows) + + @pytest.mark.benchmark(group="ntf_5cols_100rows") + def test_cached_5cols_100rows(self, benchmark): + colnames = make_colnames(5) + rows = make_rows(5, 100) + _named_tuple_row_class.cache_clear() + # Warm the cache with one call + named_tuple_factory(colnames, rows) + benchmark(named_tuple_factory, colnames, rows) + + # --- 10 columns, 100 rows --- + + @pytest.mark.benchmark(group="ntf_10cols_100rows") + def test_uncached_10cols_100rows(self, benchmark): + colnames = make_colnames(10) + rows = make_rows(10, 100) + benchmark(named_tuple_factory_uncached, colnames, rows) + + @pytest.mark.benchmark(group="ntf_10cols_100rows") + def test_cached_10cols_100rows(self, benchmark): + colnames = make_colnames(10) + rows = make_rows(10, 100) + _named_tuple_row_class.cache_clear() + named_tuple_factory(colnames, rows) + benchmark(named_tuple_factory, colnames, rows) + + # --- 20 columns, 100 rows --- + + @pytest.mark.benchmark(group="ntf_20cols_100rows") + def test_uncached_20cols_100rows(self, benchmark): + colnames = make_colnames(20) + rows = make_rows(20, 100) + benchmark(named_tuple_factory_uncached, colnames, rows) + + @pytest.mark.benchmark(group="ntf_20cols_100rows") + def test_cached_20cols_100rows(self, benchmark): + colnames = make_colnames(20) + rows = make_rows(20, 100) + _named_tuple_row_class.cache_clear() + named_tuple_factory(colnames, rows) + benchmark(named_tuple_factory, colnames, rows) + + # --- 5 columns, 1000 rows --- + + @pytest.mark.benchmark(group="ntf_5cols_1000rows") + def test_uncached_5cols_1000rows(self, benchmark): + colnames = make_colnames(5) + rows = make_rows(5, 1000) + benchmark(named_tuple_factory_uncached, colnames, rows) + + @pytest.mark.benchmark(group="ntf_5cols_1000rows") + def test_cached_5cols_1000rows(self, benchmark): + colnames = make_colnames(5) + rows = make_rows(5, 1000) + _named_tuple_row_class.cache_clear() + named_tuple_factory(colnames, rows) + benchmark(named_tuple_factory, colnames, rows) + + # --- 10 columns, 1 row (measures class creation overhead most clearly) --- + + @pytest.mark.benchmark(group="ntf_10cols_1row") + def test_uncached_10cols_1row(self, benchmark): + colnames = make_colnames(10) + rows = make_rows(10, 1) + benchmark(named_tuple_factory_uncached, colnames, rows) + + @pytest.mark.benchmark(group="ntf_10cols_1row") + def test_cached_10cols_1row(self, benchmark): + colnames = make_colnames(10) + rows = make_rows(10, 1) + _named_tuple_row_class.cache_clear() + named_tuple_factory(colnames, rows) + benchmark(named_tuple_factory, colnames, rows) diff --git a/cassandra/query.py b/cassandra/query.py index 39b9fdb0ad..e9c0d9e4fc 100644 --- a/cassandra/query.py +++ b/cassandra/query.py @@ -19,11 +19,11 @@ """ from collections import namedtuple +import functools from datetime import datetime, timedelta, timezone import re import struct import time -import warnings from cassandra import ConsistencyLevel, OperationTimedOut from cassandra.util import unix_time_from_uuid1, maybe_add_timeout_to_query @@ -117,6 +117,22 @@ def pseudo_namedtuple_factory(colnames, rows): for od in ordered_dict_factory(colnames, rows)] +# Keyed on the raw column-name tuple; bounded so ad hoc schemas can't grow it forever. +@functools.lru_cache(maxsize=10000) +def _named_tuple_row_class(colnames): + clean_column_names = [_clean_column_name(c) for c in colnames] + try: + return namedtuple('Row', clean_column_names) + except Exception: + log.warning("Failed creating named tuple for results with column names %s (cleaned: %s) " + "(see Python 'namedtuple' documentation for details on name rules). " + "Results will be returned with positional names. " + "Avoid this by choosing different names, using SELECT \"\" AS aliases, " + "or specifying a different row_factory on your Session" % + (list(colnames), clean_column_names)) + return namedtuple('Row', _sanitize_identifiers(clean_column_names)) + + def named_tuple_factory(colnames, rows): """ Returns each row as a `namedtuple `_. @@ -146,33 +162,7 @@ def named_tuple_factory(colnames, rows): .. versionchanged:: 2.0.0 moved from ``cassandra.decoder`` to ``cassandra.query`` """ - clean_column_names = map(_clean_column_name, colnames) - try: - Row = namedtuple('Row', clean_column_names) - except SyntaxError: - warnings.warn( - "Failed creating namedtuple for a result because there were too " - "many columns. This is due to a Python limitation that affects " - "namedtuple in Python 3.0-3.6 (see issue18896). The row will be " - "created with {substitute_factory_name}, which lacks some namedtuple " - "features and is slower. To avoid slower performance accessing " - "values on row objects, Upgrade to Python 3.7, or use a different " - "row factory. (column names: {colnames})".format( - substitute_factory_name=pseudo_namedtuple_factory.__name__, - colnames=colnames - ) - ) - return pseudo_namedtuple_factory(colnames, rows) - except Exception: - clean_column_names = list(map(_clean_column_name, colnames)) # create list because py3 map object will be consumed by first attempt - log.warning("Failed creating named tuple for results with column names %s (cleaned: %s) " - "(see Python 'namedtuple' documentation for details on name rules). " - "Results will be returned with positional names. " - "Avoid this by choosing different names, using SELECT \"\" AS aliases, " - "or specifying a different row_factory on your Session" % - (colnames, clean_column_names)) - Row = namedtuple('Row', _sanitize_identifiers(clean_column_names)) - + Row = _named_tuple_row_class(tuple(colnames)) return [Row(*row) for row in rows] diff --git a/pyproject.toml b/pyproject.toml index 26c720c765..27f31346bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ dev = [ # Keep the patched 9.x range without a literal "<": cibuildwheel runs # test-group installs through cmd.exe, where "<" is input redirection. "pytest~=9.1", + "pytest-benchmark", "PyYAML", "pure-sasl", "cryptography>=42.0", diff --git a/tests/unit/test_row_factories.py b/tests/unit/test_row_factories.py index 7787f1d271..6de39f49ae 100644 --- a/tests/unit/test_row_factories.py +++ b/tests/unit/test_row_factories.py @@ -13,20 +13,18 @@ # limitations under the License. -from cassandra.query import named_tuple_factory +from cassandra.query import named_tuple_factory, _named_tuple_row_class import logging import warnings -import sys - from unittest import TestCase +import pytest -log = logging.getLogger(__name__) +log = logging.getLogger(__name__) -NAMEDTUPLE_CREATION_BUG = sys.version_info >= (3,) and sys.version_info < (3, 7) class TestNamedTupleFactory(TestCase): @@ -51,21 +49,11 @@ def test_creation_warning_on_long_column_list(self): @since 3.15 @jira_ticket PYTHON-893 - @expected_result creation fails on Python > 3 and < 3.7 + @expected_result creates namedtuple-based Rows (no 255-field limit since Python 3.7) @test_category row_factory """ - if not NAMEDTUPLE_CREATION_BUG: - named_tuple_factory(self.long_colnames, self.long_rows) - return - - with warnings.catch_warnings(record=True) as w: - rows = named_tuple_factory(self.long_colnames, self.long_rows) - assert len(w) == 1 - warning = w[0] - assert 'pseudo_namedtuple_factory' in str(warning) - assert '3.7' in str(warning) - + rows = named_tuple_factory(self.long_colnames, self.long_rows) for r in rows: assert r.col0 == self.long_rows[0][0] @@ -85,3 +73,70 @@ def test_creation_no_warning_on_short_column_list(self): # check that this is a real namedtuple assert hasattr(rows[0], '_fields') assert isinstance(rows[0], tuple) + + +def named_tuple_factory_uncached(colnames, rows): + Row = _named_tuple_row_class.__wrapped__(tuple(colnames)) + return [Row(*row) for row in rows] + + +def make_colnames(n): + return tuple(f"col_{i}" for i in range(n)) + + +def make_rows(ncols, nrows): + return [tuple(range(ncols)) for _ in range(nrows)] + + +class TestNamedTupleFactoryCache: + """Verify the cached implementation matches the uncached one and is keyed correctly.""" + + @pytest.mark.parametrize("ncols", [1, 5, 10, 20]) + @pytest.mark.parametrize("nrows", [1, 10, 100]) + def test_results_match(self, ncols, nrows): + colnames = make_colnames(ncols) + rows = make_rows(ncols, nrows) + _named_tuple_row_class.cache_clear() + cached_result = named_tuple_factory(colnames, rows) + uncached_result = named_tuple_factory_uncached(colnames, rows) + assert len(cached_result) == len(uncached_result) + for cr, ur in zip(cached_result, uncached_result): + assert tuple(cr) == tuple(ur) + assert cr._fields == ur._fields + + def test_cache_hit_returns_same_class(self): + colnames = ("name", "age", "email") + rows1 = [("Alice", 30, "a@b.com")] + rows2 = [("Bob", 25, "b@c.com")] + _named_tuple_row_class.cache_clear() + result1 = named_tuple_factory(colnames, rows1) + result2 = named_tuple_factory(colnames, rows2) + # Same Row class should be reused + assert type(result1[0]) is type(result2[0]) + + def test_different_schemas_get_different_classes(self): + _named_tuple_row_class.cache_clear() + result1 = named_tuple_factory(("a", "b"), [(1, 2)]) + result2 = named_tuple_factory(("x", "y"), [(3, 4)]) + assert type(result1[0]) is not type(result2[0]) + assert result1[0]._fields == ("a", "b") + assert result2[0]._fields == ("x", "y") + + def test_case_difference_does_not_collide(self): + # Same names modulo case must not share a cached Row class: the raw + # (uncleaned) column names differ, so the cache key differs too. + _named_tuple_row_class.cache_clear() + result1 = named_tuple_factory(("Name", "Age"), [("Alice", 30)]) + result2 = named_tuple_factory(("name", "age"), [("bob", 25)]) + assert type(result1[0]) is not type(result2[0]) + assert result1[0]._fields == ("Name", "Age") + assert result2[0]._fields == ("name", "age") + + def test_column_order_does_not_collide(self): + # Same names in a different order must not share a cached Row class. + _named_tuple_row_class.cache_clear() + result1 = named_tuple_factory(("a", "b"), [(1, 2)]) + result2 = named_tuple_factory(("b", "a"), [(2, 1)]) + assert type(result1[0]) is not type(result2[0]) + assert result1[0]._fields == ("a", "b") + assert result2[0]._fields == ("b", "a") From 003c6b165a69e3ab69b09ec09f173d826b5bb15e Mon Sep 17 00:00:00 2001 From: Yaniv Kaul Date: Wed, 30 Sep 2026 09:34:07 +0300 Subject: [PATCH 200/200] address review: thread-safe single-flight row-class cache - replace the lru_cache with a bounded FIFO cache guarded by an RLock: concurrent misses of a cold schema now all receive the same Row class, while the hit path stays lock-free (dkropachev review). - lower the cache bound to 1024 classes and cover eviction in a unit test (dkropachev/CodeRabbit review). - cover the invalid-identifier sanitization path and that its warning is logged once per schema (dkropachev review). - correct the pseudo_namedtuple_factory docs: it is no longer an automatic fallback of named_tuple_factory (dkropachev review). Signed-off-by: Yaniv Michael Kaul --- .../test_named_tuple_factory_benchmark.py | 171 ------------------ cassandra/query.py | 54 +++++- pyproject.toml | 1 - tests/unit/test_row_factories.py | 127 ++++++++----- 4 files changed, 134 insertions(+), 219 deletions(-) delete mode 100644 benchmarks/test_named_tuple_factory_benchmark.py diff --git a/benchmarks/test_named_tuple_factory_benchmark.py b/benchmarks/test_named_tuple_factory_benchmark.py deleted file mode 100644 index 649b752229..0000000000 --- a/benchmarks/test_named_tuple_factory_benchmark.py +++ /dev/null @@ -1,171 +0,0 @@ -# Copyright ScyllaDB, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -""" -Benchmarks for named_tuple_factory with and without namedtuple class caching. - -Run with: pytest benchmarks/test_named_tuple_factory_benchmark.py -v - -Correctness tests for the same cache (cache-hit, cache-key, and eviction -behavior) live in tests/unit/test_row_factories.py instead of here, because -the project's wheel test commands only run `pytest tests/unit` -- code left -only under benchmarks/ is never exercised in CI. Only pure timing/benchmark -code belongs in this file. -""" - -import re -from collections import namedtuple - -import pytest - -from cassandra.query import named_tuple_factory, _named_tuple_row_class -from cassandra.util import _sanitize_identifiers - - -# --------------------------------------------------------------------------- -# Reference: original uncached implementation (copied from master) -# --------------------------------------------------------------------------- - -NON_ALPHA_REGEX = re.compile("[^a-zA-Z0-9]") -START_BADCHAR_REGEX = re.compile("^[^a-zA-Z0-9]*") -END_BADCHAR_REGEX = re.compile("[^a-zA-Z0-9_]*$") - -_clean_name_cache_old = {} - - -def _clean_column_name_old(name): - try: - return _clean_name_cache_old[name] - except KeyError: - clean = NON_ALPHA_REGEX.sub( - "_", START_BADCHAR_REGEX.sub("", END_BADCHAR_REGEX.sub("", name)) - ) - _clean_name_cache_old[name] = clean - return clean - - -def named_tuple_factory_uncached(colnames, rows): - """Original implementation without caching (for benchmark comparison).""" - clean_column_names = map(_clean_column_name_old, colnames) - try: - Row = namedtuple("Row", clean_column_names) - except SyntaxError: - raise - except Exception: - clean_column_names = list(map(_clean_column_name_old, colnames)) - Row = namedtuple("Row", _sanitize_identifiers(clean_column_names)) - return [Row(*row) for row in rows] - - -# --------------------------------------------------------------------------- -# Test data generators -# --------------------------------------------------------------------------- - - -def make_colnames(n): - return tuple(f"col_{i}" for i in range(n)) - - -def make_rows(ncols, nrows): - return [tuple(range(ncols)) for _ in range(nrows)] - - -# --------------------------------------------------------------------------- -# Benchmarks -# --------------------------------------------------------------------------- - - -class TestNamedTupleFactoryBenchmark: - """Benchmark cached vs uncached named_tuple_factory.""" - - # --- 5 columns, 100 rows --- - - @pytest.mark.benchmark(group="ntf_5cols_100rows") - def test_uncached_5cols_100rows(self, benchmark): - colnames = make_colnames(5) - rows = make_rows(5, 100) - benchmark(named_tuple_factory_uncached, colnames, rows) - - @pytest.mark.benchmark(group="ntf_5cols_100rows") - def test_cached_5cols_100rows(self, benchmark): - colnames = make_colnames(5) - rows = make_rows(5, 100) - _named_tuple_row_class.cache_clear() - # Warm the cache with one call - named_tuple_factory(colnames, rows) - benchmark(named_tuple_factory, colnames, rows) - - # --- 10 columns, 100 rows --- - - @pytest.mark.benchmark(group="ntf_10cols_100rows") - def test_uncached_10cols_100rows(self, benchmark): - colnames = make_colnames(10) - rows = make_rows(10, 100) - benchmark(named_tuple_factory_uncached, colnames, rows) - - @pytest.mark.benchmark(group="ntf_10cols_100rows") - def test_cached_10cols_100rows(self, benchmark): - colnames = make_colnames(10) - rows = make_rows(10, 100) - _named_tuple_row_class.cache_clear() - named_tuple_factory(colnames, rows) - benchmark(named_tuple_factory, colnames, rows) - - # --- 20 columns, 100 rows --- - - @pytest.mark.benchmark(group="ntf_20cols_100rows") - def test_uncached_20cols_100rows(self, benchmark): - colnames = make_colnames(20) - rows = make_rows(20, 100) - benchmark(named_tuple_factory_uncached, colnames, rows) - - @pytest.mark.benchmark(group="ntf_20cols_100rows") - def test_cached_20cols_100rows(self, benchmark): - colnames = make_colnames(20) - rows = make_rows(20, 100) - _named_tuple_row_class.cache_clear() - named_tuple_factory(colnames, rows) - benchmark(named_tuple_factory, colnames, rows) - - # --- 5 columns, 1000 rows --- - - @pytest.mark.benchmark(group="ntf_5cols_1000rows") - def test_uncached_5cols_1000rows(self, benchmark): - colnames = make_colnames(5) - rows = make_rows(5, 1000) - benchmark(named_tuple_factory_uncached, colnames, rows) - - @pytest.mark.benchmark(group="ntf_5cols_1000rows") - def test_cached_5cols_1000rows(self, benchmark): - colnames = make_colnames(5) - rows = make_rows(5, 1000) - _named_tuple_row_class.cache_clear() - named_tuple_factory(colnames, rows) - benchmark(named_tuple_factory, colnames, rows) - - # --- 10 columns, 1 row (measures class creation overhead most clearly) --- - - @pytest.mark.benchmark(group="ntf_10cols_1row") - def test_uncached_10cols_1row(self, benchmark): - colnames = make_colnames(10) - rows = make_rows(10, 1) - benchmark(named_tuple_factory_uncached, colnames, rows) - - @pytest.mark.benchmark(group="ntf_10cols_1row") - def test_cached_10cols_1row(self, benchmark): - colnames = make_colnames(10) - rows = make_rows(10, 1) - _named_tuple_row_class.cache_clear() - named_tuple_factory(colnames, rows) - benchmark(named_tuple_factory, colnames, rows) diff --git a/cassandra/query.py b/cassandra/query.py index e9c0d9e4fc..4e6f55a357 100644 --- a/cassandra/query.py +++ b/cassandra/query.py @@ -19,10 +19,10 @@ """ from collections import namedtuple -import functools from datetime import datetime, timedelta, timezone import re import struct +import threading import time from cassandra import ConsistencyLevel, OperationTimedOut @@ -110,16 +110,17 @@ def __repr__(self): def pseudo_namedtuple_factory(colnames, rows): """ - Returns each row as a :class:`.PseudoNamedTupleRow`. This is the fallback - factory for cases where :meth:`.named_tuple_factory` fails to create rows. + Returns each row as a :class:`.PseudoNamedTupleRow`. Not used as an + automatic fallback by :meth:`.named_tuple_factory`. """ return [PseudoNamedTupleRow(od) for od in ordered_dict_factory(colnames, rows)] -# Keyed on the raw column-name tuple; bounded so ad hoc schemas can't grow it forever. -@functools.lru_cache(maxsize=10000) -def _named_tuple_row_class(colnames): +_NAMED_TUPLE_ROW_CLASS_CACHE_SIZE = 1024 + + +def _build_named_tuple_row_class(colnames): clean_column_names = [_clean_column_name(c) for c in colnames] try: return namedtuple('Row', clean_column_names) @@ -133,6 +134,43 @@ def _named_tuple_row_class(colnames): return namedtuple('Row', _sanitize_identifiers(clean_column_names)) +class _NamedTupleRowClassCache(object): + """ + Bounded FIFO cache of ``Row`` classes keyed on the raw column-name tuple. + + Hits are lock-free (LRU reordering would need the lock). Builds run outside + the lock and the first insert wins, so concurrent misses share one class. + """ + + def __init__(self, maxsize): + self._maxsize = maxsize + self._lock = threading.RLock() + self._cache = OrderedDict() + + def __call__(self, colnames): + try: + return self._cache[colnames] + except KeyError: + pass + # Build outside the lock so unrelated cold schemas don't queue; first insert wins. + row_class = _build_named_tuple_row_class(colnames) + with self._lock: + existing = self._cache.get(colnames) + if existing is not None: + return existing + self._cache[colnames] = row_class + if len(self._cache) > self._maxsize: + self._cache.popitem(last=False) + return row_class + + def cache_clear(self): + with self._lock: + self._cache.clear() + + +_named_tuple_row_class = _NamedTupleRowClassCache(_NAMED_TUPLE_ROW_CLASS_CACHE_SIZE) + + def named_tuple_factory(colnames, rows): """ Returns each row as a `namedtuple `_. @@ -159,6 +197,10 @@ def named_tuple_factory(colnames, rows): >>> print("name: %s, age: %d" % (name, age)) name: Bob, age: 42 + Invalid identifiers, duplicates and keywords among the column names are + sanitized to positional names (``field_0_``, ...), with a warning logged + each time the schema's class is built (again after FIFO eviction). + .. versionchanged:: 2.0.0 moved from ``cassandra.decoder`` to ``cassandra.query`` """ diff --git a/pyproject.toml b/pyproject.toml index 27f31346bd..26c720c765 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,6 @@ dev = [ # Keep the patched 9.x range without a literal "<": cibuildwheel runs # test-group installs through cmd.exe, where "<" is input redirection. "pytest~=9.1", - "pytest-benchmark", "PyYAML", "pure-sasl", "cryptography>=42.0", diff --git a/tests/unit/test_row_factories.py b/tests/unit/test_row_factories.py index 6de39f49ae..4e1b9f37d1 100644 --- a/tests/unit/test_row_factories.py +++ b/tests/unit/test_row_factories.py @@ -13,9 +13,15 @@ # limitations under the License. -from cassandra.query import named_tuple_factory, _named_tuple_row_class +from cassandra.query import ( + named_tuple_factory, + _build_named_tuple_row_class, + _named_tuple_row_class, + _NAMED_TUPLE_ROW_CLASS_CACHE_SIZE, +) import logging +import threading import warnings from unittest import TestCase @@ -75,34 +81,16 @@ def test_creation_no_warning_on_short_column_list(self): assert isinstance(rows[0], tuple) -def named_tuple_factory_uncached(colnames, rows): - Row = _named_tuple_row_class.__wrapped__(tuple(colnames)) - return [Row(*row) for row in rows] - - -def make_colnames(n): - return tuple(f"col_{i}" for i in range(n)) - - -def make_rows(ncols, nrows): - return [tuple(range(ncols)) for _ in range(nrows)] - - class TestNamedTupleFactoryCache: - """Verify the cached implementation matches the uncached one and is keyed correctly.""" - @pytest.mark.parametrize("ncols", [1, 5, 10, 20]) - @pytest.mark.parametrize("nrows", [1, 10, 100]) - def test_results_match(self, ncols, nrows): - colnames = make_colnames(ncols) - rows = make_rows(ncols, nrows) + def test_results_match_uncached(self): + colnames = tuple("col_%d" % i for i in range(10)) + rows = [tuple(range(10)) for _ in range(5)] _named_tuple_row_class.cache_clear() - cached_result = named_tuple_factory(colnames, rows) - uncached_result = named_tuple_factory_uncached(colnames, rows) - assert len(cached_result) == len(uncached_result) - for cr, ur in zip(cached_result, uncached_result): - assert tuple(cr) == tuple(ur) - assert cr._fields == ur._fields + Row = _build_named_tuple_row_class(colnames) + result = named_tuple_factory(colnames, rows) + assert [tuple(r) for r in result] == [tuple(Row(*r)) for r in rows] + assert result[0]._fields == Row._fields def test_cache_hit_returns_same_class(self): colnames = ("name", "age", "email") @@ -122,21 +110,78 @@ def test_different_schemas_get_different_classes(self): assert result1[0]._fields == ("a", "b") assert result2[0]._fields == ("x", "y") - def test_case_difference_does_not_collide(self): - # Same names modulo case must not share a cached Row class: the raw - # (uncleaned) column names differ, so the cache key differs too. + def test_case_and_order_do_not_collide(self): + # The key is the raw column-name tuple, so case/order variants get separate classes. _named_tuple_row_class.cache_clear() - result1 = named_tuple_factory(("Name", "Age"), [("Alice", 30)]) - result2 = named_tuple_factory(("name", "age"), [("bob", 25)]) - assert type(result1[0]) is not type(result2[0]) - assert result1[0]._fields == ("Name", "Age") - assert result2[0]._fields == ("name", "age") + schemas = [("Name", "Age"), ("name", "age"), ("age", "name")] + classes = [type(named_tuple_factory(c, [(1, 2)])[0]) for c in schemas] + assert len(set(classes)) == 3 + assert [c._fields for c in classes] == schemas + + def test_cache_eviction_is_bounded(self): + # Inserting more schemas than maxsize must not grow the cache past its + # bound: the oldest generated classes are evicted. + _named_tuple_row_class.cache_clear() + maxsize = _NAMED_TUPLE_ROW_CLASS_CACHE_SIZE + for i in range(maxsize + 1): + _named_tuple_row_class(("col_%d" % i,)) + assert len(_named_tuple_row_class._cache) == maxsize + # FIFO: the oldest schema was evicted, the newest is retained. + assert ("col_0",) not in _named_tuple_row_class._cache + assert ("col_%d" % maxsize,) in _named_tuple_row_class._cache + + def test_concurrent_misses_share_one_class(self): + # Concurrent first use of a cold schema must yield a single Row class, + # not one per thread. + _named_tuple_row_class.cache_clear() + colnames = ("name", "age") + nthreads = 16 + barrier = threading.Barrier(nthreads) + results = [None] * nthreads + + def worker(idx): + barrier.wait() + results[idx] = _named_tuple_row_class(colnames) - def test_column_order_does_not_collide(self): - # Same names in a different order must not share a cached Row class. + threads = [threading.Thread(target=worker, args=(i,)) for i in range(nthreads)] + for t in threads: + t.start() + for t in threads: + t.join() + assert len(set(results)) == 1 + assert results[0]._fields == colnames + + +class TestNamedTupleFactoryFallback: + """Cover the invalid-identifier sanitization path and its caching.""" + + def test_invalid_identifier_is_sanitized(self): _named_tuple_row_class.cache_clear() - result1 = named_tuple_factory(("a", "b"), [(1, 2)]) - result2 = named_tuple_factory(("b", "a"), [(2, 1)]) - assert type(result1[0]) is not type(result2[0]) - assert result1[0]._fields == ("a", "b") - assert result2[0]._fields == ("b", "a") + rows = named_tuple_factory(("col", "1bad"), [(1, 2)]) + assert rows[0]._fields == ("col", "field_1_") + assert tuple(rows[0]) == (1, 2) + + def test_invalid_identifier_warning_logged_once_per_schema(self, caplog): + _named_tuple_row_class.cache_clear() + with caplog.at_level(logging.WARNING, logger="cassandra.query"): + named_tuple_factory(("1bad",), [(1,)]) + named_tuple_factory(("1bad",), [(2,)]) + warnings = [r for r in caplog.records + if "Failed creating named tuple" in r.getMessage()] + assert len(warnings) == 1 + + def test_invalid_identifier_warning_per_distinct_schema(self, caplog): + _named_tuple_row_class.cache_clear() + with caplog.at_level(logging.WARNING, logger="cassandra.query"): + named_tuple_factory(("1bad",), [(1,)]) + named_tuple_factory(("2bad",), [(1,)]) + named_tuple_factory(("2bad",), [(2,)]) + warnings = [r for r in caplog.records + if "Failed creating named tuple" in r.getMessage()] + assert len(warnings) == 2 + + def test_duplicate_and_keyword_columns_are_sanitized(self): + _named_tuple_row_class.cache_clear() + rows = named_tuple_factory(("a", "a", "class"), [(1, 2, 3)]) + assert tuple(rows[0]) == (1, 2, 3) + assert len(set(rows[0]._fields)) == 3