diff --git a/.github/.release-please-manifest.json b/.github/.release-please-manifest.json index 1d17cc812..1c273a818 100644 --- a/.github/.release-please-manifest.json +++ b/.github/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "4.14.2" + ".": "4.15.0-rc2" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ce24ff24..d873df1d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,47 @@ # Changelog +## [4.15.0-rc2](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.15.0-rc.1...testcontainers-v4.15.0-rc2) (2026-04-30) + + +### Features + +* **core:** support TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX ([#961](https://github.com/testcontainers/testcontainers-python/issues/961)) ([be9a0a6](https://github.com/testcontainers/testcontainers-python/commit/be9a0a612d934c77bdde20defd4d9f7d5228fb0c)) +* **mongodb:** Add Atlas Local for MongoDb ([#873](https://github.com/testcontainers/testcontainers-python/issues/873)) ([73aeb43](https://github.com/testcontainers/testcontainers-python/commit/73aeb43c18d56993d7c2626fb598a01842a91c35)) +* support with_copy_to ([#976](https://github.com/testcontainers/testcontainers-python/issues/976)) ([59ec1ce](https://github.com/testcontainers/testcontainers-python/commit/59ec1ce6dc7d54fa7f4b3c69f5bf674dfd19bfc0)) +* **valkey:** add Valkey module ([#947](https://github.com/testcontainers/testcontainers-python/issues/947)) ([fc09dc1](https://github.com/testcontainers/testcontainers-python/commit/fc09dc17bccd45d57d92f12c0de26b99ab1ccecf)) + + +### Bug Fixes + +* **azurite:** use `HttpWaitStrategy` instead of deprecated `wait_container_is_ready` ([#1003](https://github.com/testcontainers/testcontainers-python/issues/1003)) ([9fe6b07](https://github.com/testcontainers/testcontainers-python/commit/9fe6b074852e5d6f1df2942bda52ee0557e5cb32)), closes [#874](https://github.com/testcontainers/testcontainers-python/issues/874) +* fix pr [#961](https://github.com/testcontainers/testcontainers-python/issues/961) ([#1011](https://github.com/testcontainers/testcontainers-python/issues/1011)) ([8eff908](https://github.com/testcontainers/testcontainers-python/commit/8eff90851eecaf5720021d63e852a927c47f978c)) + +## [4.15.0-rc.1](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.14.2...testcontainers-v4.15.0-rc.1) (2026-04-07) + + +### Features + +* **compose:** add structured container inspect information ([#897](https://github.com/testcontainers/testcontainers-python/issues/897)) ([58459a1](https://github.com/testcontainers/testcontainers-python/commit/58459a13a1523c5dec8b21b0e16ae1afdce48156)) +* **core:** support SSH-based DOCKER_HOST ([#993](https://github.com/testcontainers/testcontainers-python/issues/993)) ([d48115d](https://github.com/testcontainers/testcontainers-python/commit/d48115def127644964d4d2b09a38e3f4492cc43c)) +* **generic:** Reintroducing the generic SQL module ([#892](https://github.com/testcontainers/testcontainers-python/issues/892)) ([2ca2321](https://github.com/testcontainers/testcontainers-python/commit/2ca2321ada12e09d491280c8ec855bf8511de7c2)) +* **keycloak:** support for relative path and management relative path ([#982](https://github.com/testcontainers/testcontainers-python/issues/982)) ([898faf6](https://github.com/testcontainers/testcontainers-python/commit/898faf6a5955698958be6e8cfd32b87323d62a44)) +* **mqtt:** MosquittoContainer: Add version 2.1.2 ([#978](https://github.com/testcontainers/testcontainers-python/issues/978)) ([af382f7](https://github.com/testcontainers/testcontainers-python/commit/af382f74e82bdcb14eac3f4e04a83432ae9beeba)) + + +### Bug Fixes + +* **azurite:** make visible to type checkers ([#927](https://github.com/testcontainers/testcontainers-python/issues/927)) ([baa5668](https://github.com/testcontainers/testcontainers-python/commit/baa566814b22fa922094a625ff92037cbe8bd93f)) +* **clickhouse:** add `HttpWaitStrategy` instead of deprecated `wait_container_is_ready` ([#962](https://github.com/testcontainers/testcontainers-python/issues/962)) ([8034541](https://github.com/testcontainers/testcontainers-python/commit/803454147c03418b7b06601d251eb491a2cd79cf)) +* **compose:** return type in get_service_port docstring ([#939](https://github.com/testcontainers/testcontainers-python/issues/939)) ([fed65fe](https://github.com/testcontainers/testcontainers-python/commit/fed65fe14507020007c115c535364c90d4bbdde9)) +* **core:** Refactor copy file ([#996](https://github.com/testcontainers/testcontainers-python/issues/996)) ([0e0bb24](https://github.com/testcontainers/testcontainers-python/commit/0e0bb24a2bddfd8a03bebdfc3b9ff8cf8c78092b)) +* **core:** wait for ryuk more reliably, improve tests: long_running, filter logs ([#984](https://github.com/testcontainers/testcontainers-python/issues/984)) ([b12ae13](https://github.com/testcontainers/testcontainers-python/commit/b12ae13e589a4ffe326c162a38df56eb30521d69)) +* **generic:** Migrate ServerContainer from deprecated decorator to HttpWaitStrategy ([#971](https://github.com/testcontainers/testcontainers-python/issues/971)) ([460b0d8](https://github.com/testcontainers/testcontainers-python/commit/460b0d8a09635068815ea8c5c5a4e4cc1e3dfea7)) +* **kafka:** Use wait strategy instead of deprecated wait_for_logs ([#903](https://github.com/testcontainers/testcontainers-python/issues/903)) ([87332c1](https://github.com/testcontainers/testcontainers-python/commit/87332c1332a30b673aac919b48e296e21f2c1baf)) +* **postgres:** add py.typed marker to postgres module ([#849](https://github.com/testcontainers/testcontainers-python/issues/849)) ([c8a5bbd](https://github.com/testcontainers/testcontainers-python/commit/c8a5bbdbab137e6dc5af9a7224e65972665ec84d)) +* **qdrant:** migrate Qdrant from deprecated decorator. ([#963](https://github.com/testcontainers/testcontainers-python/issues/963)) ([407f798](https://github.com/testcontainers/testcontainers-python/commit/407f79825be97865010dc0119cdfe3498a609a08)) +* **redis:** Use wait strategy instead of deprecated decorator ([#914](https://github.com/testcontainers/testcontainers-python/issues/914)) ([e25713a](https://github.com/testcontainers/testcontainers-python/commit/e25713a300eda6a14973d2465590d2318dcc375d)) +* **sftp:** Avoid using wait_for_logs in module. ([#995](https://github.com/testcontainers/testcontainers-python/issues/995)) ([83157eb](https://github.com/testcontainers/testcontainers-python/commit/83157eb4acd931949cfec3d2a84db0a61685e739)) + ## [4.14.2](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.14.1...testcontainers-v4.14.2) (2026-03-18) diff --git a/Makefile b/Makefile index 680b5d038..9292a84df 100644 --- a/Makefile +++ b/Makefile @@ -25,6 +25,9 @@ ${TESTS}: %/tests: quick-core-tests: ## Run core tests excluding long_running uv run coverage run --parallel -m pytest -v -m "not long_running" core/tests +core-tests: ## Run tests for the core package + uv run coverage run --parallel -m pytest -v core/tests + coverage: ## Target to combine and report coverage. uv run coverage combine uv run coverage report diff --git a/conf.py b/conf.py index 25271fd6c..3c37b2bff 100644 --- a/conf.py +++ b/conf.py @@ -168,4 +168,5 @@ nitpick_ignore = [ ("py:class", "typing_extensions.Self"), ("py:class", "docker.models.containers.ExecResult"), + ("py:class", "testcontainers.core.docker_client.ContainerInspectInfo"), ] diff --git a/core/testcontainers/compose/compose.py b/core/testcontainers/compose/compose.py index c959b1341..ffe6538ad 100644 --- a/core/testcontainers/compose/compose.py +++ b/core/testcontainers/compose/compose.py @@ -1,5 +1,5 @@ import sys -from dataclasses import asdict, dataclass, field, fields, is_dataclass +from dataclasses import asdict, dataclass, field from functools import cached_property from json import loads from logging import getLogger, warning @@ -11,28 +11,16 @@ from types import TracebackType from typing import Any, Callable, Literal, Optional, TypeVar, Union, cast +from testcontainers.core.docker_client import DockerClient, get_docker_host_hostname from testcontainers.core.exceptions import ContainerIsNotRunning, NoSuchPortExposed +from testcontainers.core.inspect import ContainerInspectInfo, _ignore_properties from testcontainers.core.waiting_utils import WaitStrategy -_IPT = TypeVar("_IPT") _WARNINGS = {"DOCKER_COMPOSE_GET_CONFIG": "get_config is experimental, see testcontainers/testcontainers-python#669"} logger = getLogger(__name__) -def _ignore_properties(cls: type[_IPT], dict_: Any) -> _IPT: - """omits extra fields like @JsonIgnoreProperties(ignoreUnknown = true) - - https://gist.github.com/alexanderankin/2a4549ac03554a31bef6eaaf2eaf7fd5""" - if isinstance(dict_, cls): - return dict_ - if not is_dataclass(cls): - raise TypeError(f"Expected a dataclass type, got {cls}") - class_fields = {f.name for f in fields(cls)} - filtered = {k: v for k, v in dict_.items() if k in class_fields} - return cls(**filtered) - - @dataclass class PublishedPortModel: """ @@ -45,10 +33,21 @@ class PublishedPortModel: Protocol: Optional[str] = None def normalize(self) -> "PublishedPortModel": - url_not_usable = system() == "Windows" and self.URL == "0.0.0.0" - if url_not_usable: + url = self.URL + + # For SSH-based DOCKER_HOST, local addresses (0.0.0.0, 127.0.0.1, localhost, ::, ::1) + # refer to the remote machine, not the local one. + # Replace them with the actual remote hostname. + ssh_host = get_docker_host_hostname() + if ssh_host and url in ("0.0.0.0", "127.0.0.1", "localhost", "::", "::1"): + url = ssh_host + # On Windows, 0.0.0.0 is not usable — replace with 127.0.0.1 + elif system() == "Windows" and url == "0.0.0.0": + url = "127.0.0.1" + + if url != self.URL: self_dict = asdict(self) - self_dict.update({"URL": "127.0.0.1"}) + self_dict.update({"URL": url}) return PublishedPortModel(**self_dict) return self @@ -81,6 +80,7 @@ class ComposeContainer: ExitCode: Optional[int] = None Publishers: list[PublishedPortModel] = field(default_factory=list) _docker_compose: Optional["DockerCompose"] = field(default=None, init=False, repr=False) + _cached_container_info: Optional[ContainerInspectInfo] = field(default=None, init=False, repr=False) def __post_init__(self) -> None: if self.Publishers: @@ -147,6 +147,28 @@ def reload(self) -> None: # each time through get_container(), but we need this method for compatibility pass + def get_container_info(self) -> Optional[ContainerInspectInfo]: + """Get container information via docker inspect (lazy loaded). + + Returns: + Container inspect information or None if container is not started. + """ + if self._cached_container_info is not None: + return self._cached_container_info + + if not self._docker_compose or not self.ID: + return None + + try: + docker_client = self._docker_compose._get_docker_client() + self._cached_container_info = docker_client.get_container_inspect_info(self.ID) + + except Exception as e: + logger.warning(f"Failed to get container info for {self.ID}: {e}") + self._cached_container_info = None + + return self._cached_container_info + @property def status(self) -> str: """Get container status for compatibility with wait strategies.""" @@ -221,6 +243,7 @@ class DockerCompose: quiet_pull: bool = False quiet_build: bool = False _wait_strategies: Optional[dict[str, Any]] = field(default=None, init=False, repr=False) + _docker_client: Optional[DockerClient] = field(default=None, init=False, repr=False) def __post_init__(self) -> None: if isinstance(self.compose_file_name, str): @@ -494,7 +517,7 @@ def get_service_port( Returns ------- - str: + int: The mapped port on the host """ normalize: PublishedPortModel = self.get_container(service_name).get_publisher(by_port=port).normalize() @@ -585,3 +608,9 @@ def wait_for(self, url: str) -> "DockerCompose": with urlopen(url) as response: response.read() return self + + def _get_docker_client(self) -> DockerClient: + """Get Docker client instance.""" + if self._docker_client is None: + self._docker_client = DockerClient() + return self._docker_client diff --git a/core/testcontainers/core/config.py b/core/testcontainers/core/config.py index c9cd8c21e..bc9be49fa 100644 --- a/core/testcontainers/core/config.py +++ b/core/testcontainers/core/config.py @@ -110,11 +110,12 @@ def _render_bool(self, env_name: str, prop_name: str) -> bool: _docker_auth_config: Optional[str] = field(default_factory=lambda: environ.get("DOCKER_AUTH_CONFIG")) tc_host_override: Optional[str] = environ.get("TC_HOST", environ.get("TESTCONTAINERS_HOST_OVERRIDE")) connection_mode_override: Optional[ConnectionMode] = field(default_factory=get_user_overwritten_connection_mode) - """ https://github.com/testcontainers/testcontainers-go/blob/dd76d1e39c654433a3d80429690d07abcec04424/docker.go#L644 if os env TC_HOST is set, use it """ + hub_image_name_prefix: str = field(default_factory=lambda: environ.get("TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX", "")) + """ Prefix to use for hub image names, e.g. for private registries. """ @property def docker_auth_config(self) -> Optional[str]: diff --git a/core/testcontainers/core/container.py b/core/testcontainers/core/container.py index 09a980b28..3fcdc9807 100644 --- a/core/testcontainers/core/container.py +++ b/core/testcontainers/core/container.py @@ -19,6 +19,7 @@ from testcontainers.core.config import testcontainers_config as c from testcontainers.core.docker_client import DockerClient from testcontainers.core.exceptions import ContainerConnectException, ContainerStartException +from testcontainers.core.inspect import ContainerInspectInfo from testcontainers.core.labels import LABEL_SESSION_ID, SESSION_ID from testcontainers.core.network import Network from testcontainers.core.transferable import Transferable, TransferSpec, build_transfer_tar @@ -88,8 +89,7 @@ def __init__( self.with_volume_mapping(*vol) self.tmpfs: dict[str, str] = {} - - self.image = image + self.image = c.hub_image_name_prefix + image self._docker = DockerClient(**(docker_client_kw or {})) self._container: Optional[Container] = None self._command: Optional[Union[str, list[str]]] = command @@ -104,6 +104,7 @@ def __init__( self._kwargs = kwargs self._wait_strategy: Optional[WaitStrategy] = _wait_strategy + self._cached_container_info: Optional[ContainerInspectInfo] = None self._transferable_specs: list[TransferSpec] = [] if transferables: @@ -202,10 +203,9 @@ def start(self) -> Self: else {} ) - self._container = docker_client.run( + self._container = docker_client.create( self.image, command=self._command, - detach=True, environment=self.env, ports=cast("dict[int, Optional[int]]", self.ports), name=self._name, @@ -214,14 +214,16 @@ def start(self) -> Self: **{**network_kwargs, **self._kwargs}, ) + for t in self._transferable_specs: + self._transfer_into_container(*t) + + docker_client.start(self._container) + if self._wait_strategy is not None: self._wait_strategy.wait_until_ready(self) logger.info("Container started: %s", self._container.short_id) - for t in self._transferable_specs: - self._transfer_into_container(*t) - return self def stop(self, force: bool = True, delete_volume: bool = True) -> None: @@ -328,6 +330,34 @@ def exec(self, command: Union[str, list[str]]) -> ExecResult: raise ContainerStartException("Container should be started before executing a command") return self._container.exec_run(command) + def wait(self) -> int: + """Wait for the container to stop and return its exit code.""" + if not self._container: + raise ContainerStartException("Container should be started before waiting") + result = self._container.wait() + return int(result["StatusCode"]) + + def get_container_info(self) -> Optional[ContainerInspectInfo]: + """Get container information via docker inspect (lazy loaded). + + Returns: + Container inspect information or None if container is not started. + """ + if self._cached_container_info is not None: + return self._cached_container_info + + if not self._container: + return None + + try: + self._cached_container_info = self.get_docker_client().get_container_inspect_info(self._container.id) + + except Exception as e: + logger.warning(f"Failed to get container info for {self._container.id}: {e}") + self._cached_container_info = None + + return self._cached_container_info + def _configure(self) -> None: # placeholder if subclasses want to define this and use the default start method pass diff --git a/core/testcontainers/core/docker_client.py b/core/testcontainers/core/docker_client.py index 12384c94c..799b3b342 100644 --- a/core/testcontainers/core/docker_client.py +++ b/core/testcontainers/core/docker_client.py @@ -30,6 +30,7 @@ from testcontainers.core.auth import DockerAuthInfo, parse_docker_auth_config from testcontainers.core.config import ConnectionMode from testcontainers.core.config import testcontainers_config as c +from testcontainers.core.inspect import ContainerInspectInfo from testcontainers.core.labels import SESSION_ID, create_labels if TYPE_CHECKING: @@ -68,9 +69,12 @@ def __init__(self, **kwargs: Any) -> None: if docker_host: LOGGER.info(f"using host {docker_host}") os.environ["DOCKER_HOST"] = docker_host - self.client = docker.from_env(**kwargs) - else: - self.client = docker.from_env(**kwargs) + # Use shell-based SSH client instead of paramiko to avoid conflicts with pytest stdin capture + # (paramiko's invoke library fails when reading from captured stdin). + if docker_host.startswith("ssh://"): + kwargs.setdefault("use_ssh_client", True) + + self.client = docker.from_env(**kwargs) self.client.api.headers["x-tc-sid"] = SESSION_ID self.client.api.headers["User-Agent"] = "tc-python/" + importlib.metadata.version("testcontainers") @@ -114,6 +118,43 @@ def run( ) return container + @_wrapped_container_collection + def create( + self, + image: str, + command: Optional[Union[str, list[str]]] = None, + environment: Optional[dict[str, str]] = None, + ports: Optional[dict[int, Optional[int]]] = None, + labels: Optional[dict[str, str]] = None, + **kwargs: Any, + ) -> Container: + """Create a container without starting it, pulling the image first if not present locally.""" + if "network" not in kwargs and not get_docker_host(): + host_network = self.find_host_network() + if host_network: + kwargs["network"] = host_network + + try: + # This is more or less a replication of what the self.client.containers.start does internally + self.client.images.get(image) + except docker.errors.ImageNotFound: + self.client.images.pull(image) + + container = self.client.containers.create( + image, + command=command, + environment=environment, + ports=ports, + labels=create_labels(image, labels), + **kwargs, + ) + return container + + @_wrapped_container_collection + def start(self, container: Container) -> None: + """Start a previously created container.""" + container.start() + @_wrapped_image_collection def build( self, path: str, tag: Optional[str], rm: bool = True, **kwargs: Any @@ -234,6 +275,14 @@ def host(self) -> str: host = c.tc_host_override if host: return host + + # For SSH-based connections, the Docker SDK rewrites base_url to + # "http+docker://ssh" which loses the original hostname. + # Extract it from the original DOCKER_HOST instead. + ssh_host = get_docker_host_hostname() + if ssh_host: + return ssh_host + try: url = urllib.parse.urlparse(self.client.api.base_url) except ValueError: @@ -264,9 +313,59 @@ def client_networks_create(self, name: str, param: dict[str, Any]) -> "DockerNet labels = create_labels("", param.get("labels")) return self.client.networks.create(name, **{**param, "labels": labels}) + def get_container_inspect_info(self, container_id: str) -> "ContainerInspectInfo": + """Get container inspect information with fresh data.""" + container = self.client.containers.get(container_id) + return ContainerInspectInfo.from_dict(container.attrs) + def get_docker_host() -> Optional[str]: - return c.tc_properties_get_tc_host() or os.getenv("DOCKER_HOST") + host = c.tc_properties_get_tc_host() or os.getenv("DOCKER_HOST") + if host: + return _sanitize_docker_host(host) + return None + + +def get_docker_host_hostname() -> Optional[str]: + """Extract the remote hostname from an SSH-based DOCKER_HOST. + + Returns the hostname (e.g. '192.168.1.42') when DOCKER_HOST is an ssh:// URL, or None otherwise. + """ + docker_host = get_docker_host() + if docker_host and docker_host.startswith("ssh://"): + parsed = urllib.parse.urlparse(docker_host) + if parsed.hostname: + return parsed.hostname + return None + + +def is_ssh_docker_host() -> bool: + """Check if the current DOCKER_HOST is an SSH-based connection.""" + return get_docker_host_hostname() is not None + + +def _sanitize_docker_host(docker_host: str) -> str: + """ + Sanitize the DOCKER_HOST value for compatibility with the Docker SDK. + + Strips path components from ``ssh://`` URLs because the Docker SDK + does not support them. A lone trailing ``/`` is treated as + equivalent to no path and silently normalised without a warning. + """ + if docker_host.startswith("ssh://"): + parsed = urllib.parse.urlparse(docker_host) + if parsed.path and parsed.path != "/": + sanitized = urllib.parse.urlunparse(parsed._replace(path="")) + LOGGER.warning( + "Stripped path from SSH DOCKER_HOST (unsupported by Docker SDK): %s -> %s", + docker_host, + sanitized, + ) + return sanitized + if parsed.path == "/": + # Trailing slash is harmless — strip quietly. + return urllib.parse.urlunparse(parsed._replace(path="")) + return docker_host def get_docker_auth_config() -> Optional[str]: diff --git a/core/testcontainers/core/inspect.py b/core/testcontainers/core/inspect.py new file mode 100644 index 000000000..a139422dc --- /dev/null +++ b/core/testcontainers/core/inspect.py @@ -0,0 +1,632 @@ +# +# 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. + +"""Docker Engine API data structures for container inspect responses.""" + +from dataclasses import dataclass, fields, is_dataclass +from typing import Any, Optional, TypeVar + +_IPT = TypeVar("_IPT") + + +def _ignore_properties(cls: type[_IPT], dict_: Any) -> _IPT: + """omits extra fields like @JsonIgnoreProperties(ignoreUnknown = true) + + https://gist.github.com/alexanderankin/2a4549ac03554a31bef6eaaf2eaf7fd5""" + if isinstance(dict_, cls): + return dict_ + if not is_dataclass(cls): + raise TypeError(f"Expected a dataclass type, got {cls}") + class_fields = {f.name for f in fields(cls)} + filtered = {k: v for k, v in dict_.items() if k in class_fields} + return cls(**filtered) + + +@dataclass +class ContainerLog: + """Container health check log entry.""" + + Start: Optional[str] = None + End: Optional[str] = None + ExitCode: Optional[int] = None + Output: Optional[str] = None + + +@dataclass +class ContainerHealth: + """Container health check information.""" + + Status: Optional[str] = None + FailingStreak: Optional[int] = None + Log: Optional[list[ContainerLog]] = None + + +@dataclass +class ContainerState: + """Container state information.""" + + Status: Optional[str] = None + Running: Optional[bool] = None + Paused: Optional[bool] = None + Restarting: Optional[bool] = None + OOMKilled: Optional[bool] = None + Dead: Optional[bool] = None + Pid: Optional[int] = None + ExitCode: Optional[int] = None + Error: Optional[str] = None + StartedAt: Optional[str] = None + FinishedAt: Optional[str] = None + Health: Optional[ContainerHealth] = None + + +@dataclass +class ContainerPlatform: + """Platform information for image manifest.""" + + architecture: Optional[str] = None + os: Optional[str] = None + variant: Optional[str] = None + + +@dataclass +class ContainerImageManifestDescriptor: + """Image manifest descriptor.""" + + mediaType: Optional[str] = None + digest: Optional[str] = None + size: Optional[int] = None + urls: Optional[list[str]] = None + annotations: Optional[dict[str, str]] = None + data: Optional[Any] = None + platform: Optional[ContainerPlatform] = None + artifactType: Optional[str] = None + + +@dataclass +class ContainerBlkioWeightDevice: + """Block IO weight device configuration.""" + + Path: Optional[str] = None + Weight: Optional[int] = None + + +@dataclass +class ContainerBlkioDeviceRate: + """Block IO device rate configuration.""" + + Path: Optional[str] = None + Rate: Optional[int] = None + + +@dataclass +class ContainerDeviceMapping: + """Device mapping configuration.""" + + PathOnHost: Optional[str] = None + PathInContainer: Optional[str] = None + CgroupPermissions: Optional[str] = None + + +@dataclass +class ContainerDeviceRequest: + """Device request configuration.""" + + Driver: Optional[str] = None + Count: Optional[int] = None + DeviceIDs: Optional[list[str]] = None + Capabilities: Optional[list[list[str]]] = None + Options: Optional[dict[str, str]] = None + + +@dataclass +class ContainerUlimit: + """Ulimit configuration.""" + + Name: Optional[str] = None + Soft: Optional[int] = None + Hard: Optional[int] = None + + +@dataclass +class ContainerLogConfig: + """Logging configuration.""" + + Type: Optional[str] = None + Config: Optional[dict[str, str]] = None + + +@dataclass +class ContainerPortBinding: + """Port binding configuration.""" + + HostIp: Optional[str] = None + HostPort: Optional[str] = None + + +@dataclass +class ContainerRestartPolicy: + """Restart policy configuration.""" + + Name: Optional[str] = None + MaximumRetryCount: Optional[int] = None + + +@dataclass +class ContainerBindOptions: + """Bind mount options.""" + + Propagation: Optional[str] = None + NonRecursive: Optional[bool] = None + CreateMountpoint: Optional[bool] = None + ReadOnlyNonRecursive: Optional[bool] = None + ReadOnlyForceRecursive: Optional[bool] = None + + +@dataclass +class ContainerVolumeDriverConfig: + """Volume driver configuration.""" + + Name: Optional[str] = None + Options: Optional[dict[str, str]] = None + + +@dataclass +class ContainerVolumeOptions: + """Volume mount options.""" + + NoCopy: Optional[bool] = None + Labels: Optional[dict[str, str]] = None + DriverConfig: Optional[ContainerVolumeDriverConfig] = None + Subpath: Optional[str] = None + + +@dataclass +class ContainerImageOptions: + """Image mount options.""" + + Subpath: Optional[str] = None + + +@dataclass +class ContainerTmpfsOptions: + """Tmpfs mount options.""" + + SizeBytes: Optional[int] = None + Mode: Optional[int] = None + Options: Optional[list[list[str]]] = None + + +@dataclass +class ContainerMountPoint: + """Mount point configuration.""" + + Target: Optional[str] = None + Source: Optional[str] = None + Type: Optional[str] = None + ReadOnly: Optional[bool] = None + Consistency: Optional[str] = None + BindOptions: Optional[ContainerBindOptions] = None + VolumeOptions: Optional[ContainerVolumeOptions] = None + ImageOptions: Optional[ContainerImageOptions] = None + TmpfsOptions: Optional[ContainerTmpfsOptions] = None + + +@dataclass +class ContainerHostConfig: + """Host configuration for container.""" + + CpuShares: Optional[int] = None + Memory: Optional[int] = None + CgroupParent: Optional[str] = None + BlkioWeight: Optional[int] = None + BlkioWeightDevice: Optional[list[ContainerBlkioWeightDevice]] = None + BlkioDeviceReadBps: Optional[list[ContainerBlkioDeviceRate]] = None + BlkioDeviceWriteBps: Optional[list[ContainerBlkioDeviceRate]] = None + BlkioDeviceReadIOps: Optional[list[ContainerBlkioDeviceRate]] = None + BlkioDeviceWriteIOps: Optional[list[ContainerBlkioDeviceRate]] = None + CpuPeriod: Optional[int] = None + CpuQuota: Optional[int] = None + CpuRealtimePeriod: Optional[int] = None + CpuRealtimeRuntime: Optional[int] = None + CpusetCpus: Optional[str] = None + CpusetMems: Optional[str] = None + Devices: Optional[list[ContainerDeviceMapping]] = None + DeviceCgroupRules: Optional[list[str]] = None + DeviceRequests: Optional[list[ContainerDeviceRequest]] = None + KernelMemoryTCP: Optional[int] = None + MemoryReservation: Optional[int] = None + MemorySwap: Optional[int] = None + MemorySwappiness: Optional[int] = None + NanoCpus: Optional[int] = None + OomKillDisable: Optional[bool] = None + Init: Optional[bool] = None + PidsLimit: Optional[int] = None + Ulimits: Optional[list[ContainerUlimit]] = None + CpuCount: Optional[int] = None + CpuPercent: Optional[int] = None + IOMaximumIOps: Optional[int] = None + IOMaximumBandwidth: Optional[int] = None + Binds: Optional[list[str]] = None + ContainerIDFile: Optional[str] = None + LogConfig: Optional[ContainerLogConfig] = None + NetworkMode: Optional[str] = None + PortBindings: Optional[dict[str, Optional[list[ContainerPortBinding]]]] = None + RestartPolicy: Optional[ContainerRestartPolicy] = None + AutoRemove: Optional[bool] = None + VolumeDriver: Optional[str] = None + VolumesFrom: Optional[list[str]] = None + Mounts: Optional[list[ContainerMountPoint]] = None + ConsoleSize: Optional[list[int]] = None + Annotations: Optional[dict[str, str]] = None + CapAdd: Optional[list[str]] = None + CapDrop: Optional[list[str]] = None + CgroupnsMode: Optional[str] = None + Dns: Optional[list[str]] = None + DnsOptions: Optional[list[str]] = None + DnsSearch: Optional[list[str]] = None + ExtraHosts: Optional[list[str]] = None + GroupAdd: Optional[list[str]] = None + IpcMode: Optional[str] = None + Cgroup: Optional[str] = None + Links: Optional[list[str]] = None + OomScoreAdj: Optional[int] = None + PidMode: Optional[str] = None + Privileged: Optional[bool] = None + PublishAllPorts: Optional[bool] = None + ReadonlyRootfs: Optional[bool] = None + SecurityOpt: Optional[list[str]] = None + StorageOpt: Optional[dict[str, str]] = None + Tmpfs: Optional[dict[str, str]] = None + UTSMode: Optional[str] = None + UsernsMode: Optional[str] = None + ShmSize: Optional[int] = None + Sysctls: Optional[dict[str, str]] = None + Runtime: Optional[str] = None + Isolation: Optional[str] = None + MaskedPaths: Optional[list[str]] = None + ReadonlyPaths: Optional[list[str]] = None + + def __post_init__(self) -> None: + list_conversions = [ + ("BlkioWeightDevice", ContainerBlkioWeightDevice), + ("BlkioDeviceReadBps", ContainerBlkioDeviceRate), + ("BlkioDeviceWriteBps", ContainerBlkioDeviceRate), + ("BlkioDeviceReadIOps", ContainerBlkioDeviceRate), + ("BlkioDeviceWriteIOps", ContainerBlkioDeviceRate), + ("Devices", ContainerDeviceMapping), + ("DeviceRequests", ContainerDeviceRequest), + ("Ulimits", ContainerUlimit), + ("Mounts", ContainerMountPoint), + ] + + for field_name, target_class in list_conversions: + field_value = getattr(self, field_name) + if field_value is not None and isinstance(field_value, list): + setattr( + self, + field_name, + [ + _ignore_properties(target_class, item) if isinstance(item, dict) else item + for item in field_value + ], + ) + + if self.LogConfig is not None and isinstance(self.LogConfig, dict): + self.LogConfig = _ignore_properties(ContainerLogConfig, self.LogConfig) + + if self.RestartPolicy is not None and isinstance(self.RestartPolicy, dict): + self.RestartPolicy = _ignore_properties(ContainerRestartPolicy, self.RestartPolicy) + + if self.PortBindings is not None and isinstance(self.PortBindings, dict): + for port, bindings in self.PortBindings.items(): + if bindings is not None and isinstance(bindings, list): + self.PortBindings[port] = [ + _ignore_properties(ContainerPortBinding, b) if isinstance(b, dict) else b for b in bindings + ] + + +@dataclass +class ContainerGraphDriver: + """Graph driver information.""" + + Name: Optional[str] = None + Data: Optional[dict[str, str]] = None + + +@dataclass +class ContainerMount: + """Mount information.""" + + Type: Optional[str] = None + Name: Optional[str] = None + Source: Optional[str] = None + Destination: Optional[str] = None + Driver: Optional[str] = None + Mode: Optional[str] = None + RW: Optional[bool] = None + Propagation: Optional[str] = None + + +@dataclass +class ContainerHealthcheck: + """Container healthcheck configuration.""" + + Test: Optional[list[str]] = None + Interval: Optional[int] = None + Timeout: Optional[int] = None + Retries: Optional[int] = None + StartPeriod: Optional[int] = None + StartInterval: Optional[int] = None + + +@dataclass +class ContainerConfig: + """Container configuration.""" + + Hostname: Optional[str] = None + Domainname: Optional[str] = None + User: Optional[str] = None + AttachStdin: Optional[bool] = None + AttachStdout: Optional[bool] = None + AttachStderr: Optional[bool] = None + ExposedPorts: Optional[dict[str, dict[str, Any]]] = None + Tty: Optional[bool] = None + OpenStdin: Optional[bool] = None + StdinOnce: Optional[bool] = None + Env: Optional[list[str]] = None + Cmd: Optional[list[str]] = None + Healthcheck: Optional[ContainerHealthcheck] = None + ArgsEscaped: Optional[bool] = None + Image: Optional[str] = None + Volumes: Optional[dict[str, dict[str, Any]]] = None + WorkingDir: Optional[str] = None + Entrypoint: Optional[list[str]] = None + NetworkDisabled: Optional[bool] = None + MacAddress: Optional[str] = None + OnBuild: Optional[list[str]] = None + Labels: Optional[dict[str, str]] = None + StopSignal: Optional[str] = None + StopTimeout: Optional[int] = None + Shell: Optional[list[str]] = None + + +@dataclass +class ContainerIPAMConfig: + """IPAM configuration for network.""" + + IPv4Address: Optional[str] = None + IPv6Address: Optional[str] = None + LinkLocalIPs: Optional[list[str]] = None + + +@dataclass +class ContainerNetworkEndpoint: + """Network endpoint information.""" + + IPAMConfig: Optional[ContainerIPAMConfig] = None + Links: Optional[list[str]] = None + MacAddress: Optional[str] = None + Aliases: Optional[list[str]] = None + DriverOpts: Optional[dict[str, str]] = None + GwPriority: Optional[list[int]] = None + NetworkID: Optional[str] = None + EndpointID: Optional[str] = None + Gateway: Optional[str] = None + IPAddress: Optional[str] = None + IPPrefixLen: Optional[int] = None + IPv6Gateway: Optional[str] = None + GlobalIPv6Address: Optional[str] = None + GlobalIPv6PrefixLen: Optional[int] = None + DNSNames: Optional[list[str]] = None + + +@dataclass +class ContainerAddress: + """IP address information.""" + + Addr: Optional[str] = None + PrefixLen: Optional[int] = None + + +@dataclass +class ContainerNetworkSettings: + """Network settings for container.""" + + Bridge: Optional[str] = None + SandboxID: Optional[str] = None + HairpinMode: Optional[bool] = None + LinkLocalIPv6Address: Optional[str] = None + LinkLocalIPv6PrefixLen: Optional[str] = None + Ports: Optional[dict[str, Optional[list[ContainerPortBinding]]]] = None + SandboxKey: Optional[str] = None + SecondaryIPAddresses: Optional[list[ContainerAddress]] = None + SecondaryIPv6Addresses: Optional[list[ContainerAddress]] = None + EndpointID: Optional[str] = None + Gateway: Optional[str] = None + GlobalIPv6Address: Optional[str] = None + GlobalIPv6PrefixLen: Optional[int] = None + IPAddress: Optional[str] = None + IPPrefixLen: Optional[int] = None + IPv6Gateway: Optional[str] = None + MacAddress: Optional[str] = None + Networks: Optional[dict[str, ContainerNetworkEndpoint]] = None + + def __post_init__(self) -> None: + if self.Ports is not None and isinstance(self.Ports, dict): + for port, bindings in self.Ports.items(): + if bindings is not None and isinstance(bindings, list): + self.Ports[port] = [ + _ignore_properties(ContainerPortBinding, b) if isinstance(b, dict) else b for b in bindings + ] + + if self.Networks is not None and isinstance(self.Networks, dict): + for name, network_data in self.Networks.items(): + if isinstance(network_data, dict): + self.Networks[name] = _ignore_properties(ContainerNetworkEndpoint, network_data) + + if self.SecondaryIPAddresses is not None and isinstance(self.SecondaryIPAddresses, list): + self.SecondaryIPAddresses = [ + _ignore_properties(ContainerAddress, addr) if isinstance(addr, dict) else addr + for addr in self.SecondaryIPAddresses + ] + + if self.SecondaryIPv6Addresses is not None and isinstance(self.SecondaryIPv6Addresses, list): + self.SecondaryIPv6Addresses = [ + _ignore_properties(ContainerAddress, addr) if isinstance(addr, dict) else addr + for addr in self.SecondaryIPv6Addresses + ] + + def get_networks(self) -> Optional[dict[str, ContainerNetworkEndpoint]]: + """Get networks for the container.""" + return self.Networks + + +@dataclass +class ContainerInspectInfo: + """Complete container information from docker inspect.""" + + Id: Optional[str] = None + Created: Optional[str] = None + Path: Optional[str] = None + Args: Optional[list[str]] = None + State: Optional[ContainerState] = None + Image: Optional[str] = None + ResolvConfPath: Optional[str] = None + HostnamePath: Optional[str] = None + HostsPath: Optional[str] = None + LogPath: Optional[str] = None + Name: Optional[str] = None + RestartCount: Optional[int] = None + Driver: Optional[str] = None + Platform: Optional[str] = None + ImageManifestDescriptor: Optional[ContainerImageManifestDescriptor] = None + MountLabel: Optional[str] = None + ProcessLabel: Optional[str] = None + AppArmorProfile: Optional[str] = None + ExecIDs: Optional[list[str]] = None + HostConfig: Optional[ContainerHostConfig] = None + GraphDriver: Optional[ContainerGraphDriver] = None + SizeRw: Optional[str] = None + SizeRootFs: Optional[str] = None + Mounts: Optional[list[ContainerMount]] = None + Config: Optional[ContainerConfig] = None + NetworkSettings: Optional[ContainerNetworkSettings] = None + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "ContainerInspectInfo": + """Create from docker inspect JSON.""" + return cls( + Id=data.get("Id"), + Created=data.get("Created"), + Path=data.get("Path"), + Args=data.get("Args"), + State=cls._parse_state(data.get("State", {})) if data.get("State") else None, + Image=data.get("Image"), + ResolvConfPath=data.get("ResolvConfPath"), + HostnamePath=data.get("HostnamePath"), + HostsPath=data.get("HostsPath"), + LogPath=data.get("LogPath"), + Name=data.get("Name"), + RestartCount=data.get("RestartCount"), + Driver=data.get("Driver"), + Platform=data.get("Platform"), + ImageManifestDescriptor=cls._parse_image_manifest(data.get("ImageManifestDescriptor", {})) + if data.get("ImageManifestDescriptor") + else None, + MountLabel=data.get("MountLabel"), + ProcessLabel=data.get("ProcessLabel"), + AppArmorProfile=data.get("AppArmorProfile"), + ExecIDs=data.get("ExecIDs"), + HostConfig=_ignore_properties(ContainerHostConfig, data.get("HostConfig", {})) + if data.get("HostConfig") + else None, + GraphDriver=_ignore_properties(ContainerGraphDriver, data.get("GraphDriver", {})) + if data.get("GraphDriver") + else None, + SizeRw=data.get("SizeRw"), + SizeRootFs=data.get("SizeRootFs"), + Mounts=[_ignore_properties(ContainerMount, mount) for mount in data.get("Mounts", [])], + Config=_ignore_properties(ContainerConfig, data.get("Config", {})) if data.get("Config") else None, + NetworkSettings=_ignore_properties(ContainerNetworkSettings, data.get("NetworkSettings", {})) + if data.get("NetworkSettings") + else None, + ) + + @classmethod + def _parse_state(cls, data: dict[str, Any]) -> Optional[ContainerState]: + """Parse State with nested Health object.""" + if not data: + return None + + health_data = data.get("Health", {}) + health = None + if health_data: + logs = [_ignore_properties(ContainerLog, log) for log in health_data.get("Log", [])] + health = ContainerHealth( + Status=health_data.get("Status"), + FailingStreak=health_data.get("FailingStreak"), + Log=logs if logs else None, + ) + + return ContainerState( + Status=data.get("Status"), + Running=data.get("Running"), + Paused=data.get("Paused"), + Restarting=data.get("Restarting"), + OOMKilled=data.get("OOMKilled"), + Dead=data.get("Dead"), + Pid=data.get("Pid"), + ExitCode=data.get("ExitCode"), + Error=data.get("Error"), + StartedAt=data.get("StartedAt"), + FinishedAt=data.get("FinishedAt"), + Health=health, + ) + + @classmethod + def _parse_image_manifest(cls, data: dict[str, Any]) -> Optional[ContainerImageManifestDescriptor]: + """Parse ImageManifestDescriptor with nested Platform.""" + if not data: + return None + + platform_data = data.get("platform", {}) + platform = _ignore_properties(ContainerPlatform, platform_data) if platform_data else None + + return ContainerImageManifestDescriptor( + mediaType=data.get("mediaType"), + digest=data.get("digest"), + size=data.get("size"), + urls=data.get("urls"), + annotations=data.get("annotations"), + data=data.get("data"), + platform=platform, + artifactType=data.get("artifactType"), + ) + + @classmethod + def _parse_host_config(cls, data: dict[str, Any]) -> Optional[ContainerHostConfig]: + """Parse HostConfig with all nested objects.""" + if not data: + return None + return _ignore_properties(ContainerHostConfig, data) + + @classmethod + def _parse_network_settings(cls, data: dict[str, Any]) -> Optional[ContainerNetworkSettings]: + """Parse NetworkSettings with nested Networks and Ports.""" + if not data: + return None + return _ignore_properties(ContainerNetworkSettings, data) + + def get_network_settings(self) -> Optional[ContainerNetworkSettings]: + """Get network settings for the container.""" + return self.NetworkSettings diff --git a/core/tests/compose_fixtures/port_multiple/compose.yaml b/core/tests/compose_fixtures/port_multiple/compose.yaml index 662079f5e..21a4f5e8c 100644 --- a/core/tests/compose_fixtures/port_multiple/compose.yaml +++ b/core/tests/compose_fixtures/port_multiple/compose.yaml @@ -7,7 +7,6 @@ services: - '82' - target: 80 published: "5000-5999" - host_ip: 127.0.0.1 protocol: tcp command: - sh @@ -20,7 +19,6 @@ services: ports: - target: 80 published: "5000-5999" - host_ip: 127.0.0.1 protocol: tcp command: - sh diff --git a/core/tests/compose_fixtures/port_single/compose.yaml b/core/tests/compose_fixtures/port_single/compose.yaml index 88c19ab61..362a3c6b2 100644 --- a/core/tests/compose_fixtures/port_single/compose.yaml +++ b/core/tests/compose_fixtures/port_single/compose.yaml @@ -4,7 +4,6 @@ services: init: true ports: - target: 80 - host_ip: 127.0.0.1 protocol: tcp command: - sh diff --git a/core/tests/test_compose.py b/core/tests/test_compose.py index ee39ec0c0..7ea26082a 100644 --- a/core/tests/test_compose.py +++ b/core/tests/test_compose.py @@ -9,7 +9,7 @@ import pytest from pytest_mock import MockerFixture -from testcontainers.compose import DockerCompose +from testcontainers.compose import DockerCompose, ComposeContainer from testcontainers.core.exceptions import ContainerIsNotRunning, NoSuchPortExposed FIXTURES = Path(__file__).parent.joinpath("compose_fixtures") @@ -382,3 +382,85 @@ def test_compose_profile_support(profiles: Optional[list[str]], running: list[st for service in not_running: with pytest.raises(ContainerIsNotRunning): compose.get_container(service) + + +@pytest.mark.parametrize( + "docker_host_env, url, expected_url", + [ + pytest.param("ssh://user@10.0.0.5", "0.0.0.0", "10.0.0.5", id="ssh_replaces_wildcard"), + pytest.param("ssh://user@10.0.0.5", "127.0.0.1", "10.0.0.5", id="ssh_replaces_loopback"), + pytest.param("ssh://user@10.0.0.5", "::", "10.0.0.5", id="ssh_replaces_ipv6_any"), + pytest.param("tcp://localhost:2375", "0.0.0.0", "0.0.0.0", id="non_ssh_keeps_original"), + ], +) +def test_compose_normalize_rewrites_local_url_for_ssh_docker_host( + monkeypatch: pytest.MonkeyPatch, docker_host_env: str, url: str, expected_url: str +) -> None: + """When DOCKER_HOST is an SSH URL, normalize() should replace local addresses + with the remote hostname — exercising the real get_docker_host_hostname() path.""" + from testcontainers.compose.compose import PublishedPortModel + from testcontainers.core.config import testcontainers_config as tc_config + + monkeypatch.setenv("DOCKER_HOST", docker_host_env) + monkeypatch.setattr(tc_config, "tc_properties_get_tc_host", lambda: None) + + model = PublishedPortModel(URL=url, TargetPort=80, PublishedPort=9999, Protocol="tcp") + result = model.normalize() + assert result.URL == expected_url + assert result.PublishedPort == 9999 + + +def test_container_info(): + """Test get_container_info functionality""" + basic = DockerCompose(context=FIXTURES / "basic") + with basic: + container = basic.get_container("alpine") + + info = container.get_container_info() + assert info is not None + assert info.Id is not None + assert info.Name is not None + assert info.Image is not None + + assert info.State is not None + assert info.State.Status == "running" + assert info.State.Running is True + assert info.State.Pid is not None + + assert info.Config is not None + assert info.Config.Image is not None + assert info.Config.Hostname is not None + + network_settings = info.get_network_settings() + assert network_settings is not None + assert network_settings.Networks is not None + + info2 = container.get_container_info() + assert info is info2 + + +def test_container_info_network_details(): + """Test network details in container info""" + single = DockerCompose(context=FIXTURES / "port_single") + with single: + container = single.get_container() + info = container.get_container_info() + assert info is not None + + network_settings = info.get_network_settings() + assert network_settings is not None + + if network_settings.Networks: + # Test first network + network_name, network = next(iter(network_settings.Networks.items())) + assert network.IPAddress is not None + assert network.Gateway is not None + assert network.NetworkID is not None + + +def test_container_info_none_when_no_docker_compose(): + """Test get_container_info returns None when docker_compose reference is missing""" + + container = ComposeContainer() + info = container.get_container_info() + assert info is None diff --git a/core/tests/test_config.py b/core/tests/test_config.py index 435860313..ed5d6aa25 100644 --- a/core/tests/test_config.py +++ b/core/tests/test_config.py @@ -28,6 +28,15 @@ def test_read_tc_properties(monkeypatch: MonkeyPatch) -> None: assert config.tc_properties == {"tc.host": "some_value"} +def test_hub_image_name_prefix(monkeypatch: MonkeyPatch) -> None: + """ + Ensure that the hub_image_name_prefix configuration variable can be read from the environment + """ + monkeypatch.setenv("TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX", "myregistry.local/") + config = TCC() + assert config.hub_image_name_prefix == "myregistry.local/" + + def test_set_tc_properties(monkeypatch: MonkeyPatch) -> None: """ Ensure the configuration file variables can be read if no environment variable is set diff --git a/core/tests/test_container.py b/core/tests/test_container.py index 30b80f79d..aa2f66c9b 100644 --- a/core/tests/test_container.py +++ b/core/tests/test_container.py @@ -1,13 +1,19 @@ +from typing import Any + import pytest from testcontainers.core.container import DockerContainer from testcontainers.core.docker_client import DockerClient from testcontainers.core.config import ConnectionMode +from testcontainers.core.config import testcontainers_config FAKE_ID = "ABC123" class FakeContainer: + def __init__(self) -> None: + self.attrs: dict[str, Any] = {} + @property def id(self) -> str: return FAKE_ID @@ -96,3 +102,67 @@ def test_attribute(init_attr, init_value, class_attr, stored_value): """Test that the attributes set through the __init__ function are properly stored.""" with DockerContainer("ubuntu", **{init_attr: init_value}) as container: assert getattr(container, class_attr) == stored_value + + +def test_image_prefix_applied(monkeypatch: pytest.MonkeyPatch) -> None: + """Test that the hub_image_name_prefix is properly applied to the image name.""" + + # Set a prefix + test_prefix = "myregistry.example.com/" + monkeypatch.setattr(testcontainers_config, "hub_image_name_prefix", test_prefix) + + # Create a container and verify the prefix is applied + container = DockerContainer("nginx:latest") + assert container.image == "myregistry.example.com/nginx:latest" + + +def test_image_no_prefix_applied_when_empty(monkeypatch: pytest.MonkeyPatch) -> None: + """Test that when hub_image_name_prefix is empty, no prefix is applied.""" + # Set an empty prefix + monkeypatch.setattr(testcontainers_config, "hub_image_name_prefix", "") + + # Create a container and verify no prefix is applied + container = DockerContainer("nginx:latest") + assert container.image == "nginx:latest" + + +def test_container_info(): + """Test get_container_info functionality with a real container.""" + with DockerContainer("alpine:latest").with_command("sleep 30") as container: + info = container.get_container_info() + assert info is not None + assert info.Id is not None + assert info.Name is not None + assert info.Image is not None + + assert info.State is not None + assert info.State.Status == "running" + assert info.State.Running is True + assert info.State.Pid is not None + + assert info.Config is not None + assert info.Config.Image is not None + assert info.Config.Hostname is not None + + network_settings = info.get_network_settings() + assert network_settings is not None + assert network_settings.Networks is not None + + info2 = container.get_container_info() + assert info is info2 + + +def test_container_info_network_details(): + """Test network details in container info.""" + with DockerContainer("nginx:alpine") as container: + info = container.get_container_info() + assert info is not None + + network_settings = info.get_network_settings() + assert network_settings is not None + + if network_settings.Networks: + network_name, network = next(iter(network_settings.Networks.items())) + assert network.IPAddress is not None + assert network.Gateway is not None + assert network.NetworkID is not None diff --git a/core/tests/test_core_registry.py b/core/tests/test_core_registry.py index 38c37b5bd..fd65fcb0b 100644 --- a/core/tests/test_core_registry.py +++ b/core/tests/test_core_registry.py @@ -3,6 +3,9 @@ Note: Using the testcontainers-python library to test the Docker registry. This could be considered a bad practice as it is not recommended to use the same library to test itself. However, it is a very good use case for DockerRegistryContainer and allows us to test it thoroughly. + +Note2: These tests are skipped on macOS and SSH-based Docker hosts because they rely on insecure HTTP registries, +which are not supported in those environments without additional configuration. """ import json @@ -14,7 +17,7 @@ from testcontainers.core.config import testcontainers_config from testcontainers.core.container import DockerContainer -from testcontainers.core.docker_client import DockerClient +from testcontainers.core.docker_client import DockerClient, is_ssh_docker_host from testcontainers.core.waiting_utils import wait_for_logs from testcontainers.registry import DockerRegistryContainer @@ -25,6 +28,10 @@ is_mac(), reason="Docker Desktop on macOS does not support insecure private registries without daemon reconfiguration", ) +@pytest.mark.skipif( + is_ssh_docker_host(), + reason="Remote Docker via SSH requires HTTPS for non-localhost registries; insecure HTTP registries are not accessible", +) def test_missing_on_private_registry(monkeypatch): username = "user" password = "pass" @@ -50,6 +57,10 @@ def test_missing_on_private_registry(monkeypatch): is_mac(), reason="Docker Desktop on macOS does not support local insecure registries over HTTP without modifying daemon settings", ) +@pytest.mark.skipif( + is_ssh_docker_host(), + reason="Remote Docker via SSH requires HTTPS for non-localhost registries; insecure HTTP registries are not accessible", +) @pytest.mark.parametrize( "image,tag,username,password,expected_output", [ diff --git a/core/tests/test_docker_client.py b/core/tests/test_docker_client.py index 3cf7facd0..23dfe0748 100644 --- a/core/tests/test_docker_client.py +++ b/core/tests/test_docker_client.py @@ -10,7 +10,7 @@ from testcontainers.core.config import testcontainers_config as c, ConnectionMode from testcontainers.core.container import DockerContainer -from testcontainers.core.docker_client import DockerClient +from testcontainers.core.docker_client import DockerClient, is_ssh_docker_host from testcontainers.core.auth import parse_docker_auth_config from testcontainers.core.image import DockerImage from testcontainers.core import utils @@ -20,13 +20,23 @@ from docker.models.networks import Network +def _expected_from_env_kwargs(**kwargs: Any) -> dict[str, Any]: + """Build the kwargs we expect ``docker.from_env`` to be called with. + + When DOCKER_HOST is SSH-based, ``use_ssh_client=True`` is added automatically. + """ + if is_ssh_docker_host(): + kwargs.setdefault("use_ssh_client", True) + return kwargs + + def test_docker_client_from_env(): test_kwargs = {"test_kw": "test_value"} mock_docker = MagicMock(spec=docker) with patch("testcontainers.core.docker_client.docker", mock_docker): DockerClient(**test_kwargs) - mock_docker.from_env.assert_called_with(**test_kwargs) + mock_docker.from_env.assert_called_with(**_expected_from_env_kwargs(**test_kwargs)) def test_docker_client_login_no_login(): @@ -111,7 +121,7 @@ def test_container_docker_client_kw(): with patch("testcontainers.core.docker_client.docker", mock_docker): DockerContainer(image="", docker_client_kw=test_kwargs) - mock_docker.from_env.assert_called_with(**test_kwargs) + mock_docker.from_env.assert_called_with(**_expected_from_env_kwargs(**test_kwargs)) def test_image_docker_client_kw(): @@ -120,7 +130,7 @@ def test_image_docker_client_kw(): with patch("testcontainers.core.docker_client.docker", mock_docker): DockerImage(name="", path="", docker_client_kw=test_kwargs) - mock_docker.from_env.assert_called_with(**test_kwargs) + mock_docker.from_env.assert_called_with(**_expected_from_env_kwargs(**test_kwargs)) def test_host_prefer_host_override(monkeypatch: pytest.MonkeyPatch) -> None: @@ -139,6 +149,8 @@ def test_host_prefer_host_override(monkeypatch: pytest.MonkeyPatch) -> None: ], ) def test_host(monkeypatch: pytest.MonkeyPatch, base_url: str, expected: str) -> None: + if is_ssh_docker_host(): + pytest.skip("base_url parsing is not exercised under SSH (host() returns SSH hostname)") client = DockerClient() monkeypatch.setattr(client.client.api, "base_url", base_url) monkeypatch.setattr(c, "tc_host_override", None) @@ -270,6 +282,8 @@ def test_run_uses_found_network(monkeypatch: pytest.MonkeyPatch) -> None: """ If a host network is found, use it """ + if is_ssh_docker_host(): + pytest.skip("Host network discovery is skipped when DOCKER_HOST is set") client = DockerClient() @@ -293,3 +307,51 @@ def __init__(self) -> None: assert client.run("test") == "CONTAINER" assert fake_client.containers.calls[0]["network"] == "new_bridge_network" + + +@pytest.mark.parametrize( + "docker_host, expected", + [ + pytest.param("ssh://user@192.168.1.42", "ssh://user@192.168.1.42", id="no_path"), + pytest.param("ssh://user@host/", "ssh://user@host", id="trailing_slash"), + pytest.param("ssh://user@host/some/path", "ssh://user@host", id="strips_path"), + pytest.param("tcp://localhost:2375", "tcp://localhost:2375", id="tcp_unchanged"), + pytest.param("unix:///var/run/docker.sock", "unix:///var/run/docker.sock", id="unix_unchanged"), + ], +) +def test_sanitize_docker_host(docker_host: str, expected: str) -> None: + from testcontainers.core.docker_client import _sanitize_docker_host + + assert _sanitize_docker_host(docker_host) == expected + + +@pytest.mark.parametrize( + "docker_host, expected_hostname", + [ + pytest.param("ssh://user@192.168.1.42", "192.168.1.42", id="ssh_ip"), + pytest.param("ssh://user@myhost.example.com", "myhost.example.com", id="ssh_fqdn"), + pytest.param("tcp://localhost:2375", None, id="tcp_returns_none"), + pytest.param(None, None, id="unset_returns_none"), + ], +) +def test_get_docker_host_hostname(monkeypatch: pytest.MonkeyPatch, docker_host: str, expected_hostname) -> None: + from testcontainers.core.docker_client import get_docker_host_hostname + + monkeypatch.setattr(c, "tc_properties_get_tc_host", lambda: None) + if docker_host: + monkeypatch.setenv("DOCKER_HOST", docker_host) + else: + monkeypatch.delenv("DOCKER_HOST", raising=False) + assert get_docker_host_hostname() == expected_hostname + + +def test_ssh_docker_host(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify SSH DOCKER_HOST sets use_ssh_client and host() returns the remote hostname.""" + monkeypatch.setenv("DOCKER_HOST", "ssh://user@10.0.0.1") + monkeypatch.setattr(c, "tc_properties_get_tc_host", lambda: None) + monkeypatch.setattr(c, "tc_host_override", None) + mock_docker = MagicMock(spec=docker) + with patch("testcontainers.core.docker_client.docker", mock_docker): + client = DockerClient() + mock_docker.from_env.assert_called_once_with(use_ssh_client=True) + assert client.host() == "10.0.0.1" diff --git a/core/tests/test_docker_in_docker.py b/core/tests/test_docker_in_docker.py index ada83c5ff..be9703621 100644 --- a/core/tests/test_docker_in_docker.py +++ b/core/tests/test_docker_in_docker.py @@ -15,7 +15,7 @@ from testcontainers.core.labels import SESSION_ID from testcontainers.core.network import Network from testcontainers.core.container import DockerContainer -from testcontainers.core.docker_client import DockerClient, LOGGER +from testcontainers.core.docker_client import DockerClient, LOGGER, is_ssh_docker_host from testcontainers.core.utils import inside_container from testcontainers.core.utils import is_mac from testcontainers.core.waiting_utils import wait_for_logs @@ -23,6 +23,11 @@ _DIND_PYTHON_VERSION = (3, 13) +SKIP_SSH_DOCKER = pytest.mark.skipif( + is_ssh_docker_host(), + reason="DinD/DooD tests require local Docker socket access, incompatible with SSH DOCKER_HOST", +) + RUN_ONCE_IN_CI = pytest.mark.skipif( bool(os.environ.get("CI")) and tuple([*sys.version_info][:2]) != _DIND_PYTHON_VERSION, reason=( @@ -51,6 +56,7 @@ def _wait_for_dind_return_ip(client: DockerClient, dind: Container): @pytest.mark.skipif(is_mac(), reason="Docker socket forwarding (socat) is unsupported on Docker Desktop for macOS") +@SKIP_SSH_DOCKER @RUN_ONCE_IN_CI def test_wait_for_logs_docker_in_docker(): # real dind isn't possible (AFAIK) in CI @@ -84,6 +90,7 @@ def test_wait_for_logs_docker_in_docker(): is_mac(), reason="Bridge networking and Docker socket forwarding are not supported on Docker Desktop for macOS", ) +@SKIP_SSH_DOCKER @RUN_ONCE_IN_CI def test_dind_inherits_network(): client = DockerClient() @@ -168,6 +175,7 @@ def get_docker_info() -> dict[str, Any]: @pytest.mark.xfail(reason="Does not work in rootless docker i.e. github actions") @pytest.mark.inside_docker_check @pytest.mark.skipif(not os.environ.get(EXPECTED_NETWORK_VAR), reason="No expected network given") +@SKIP_SSH_DOCKER def test_find_host_network_in_dood() -> None: """ Check that the correct host network is found for DooD @@ -185,6 +193,7 @@ def test_find_host_network_in_dood() -> None: reason="Docker socket mounting and container networking do not work reliably on Docker Desktop for macOS", ) @pytest.mark.skipif(not Path(tcc.ryuk_docker_socket).exists(), reason="No docker socket available") +@SKIP_SSH_DOCKER @RUN_ONCE_IN_CI def test_dood(python_testcontainer_image: str) -> None: """ @@ -225,6 +234,7 @@ def test_dood(python_testcontainer_image: str) -> None: is_mac(), reason="Docker socket mounting and container networking do not work reliably on Docker Desktop for macOS", ) +@SKIP_SSH_DOCKER @RUN_ONCE_IN_CI def test_dind(python_testcontainer_image: str, tmp_path: Path) -> None: """ diff --git a/core/tests/test_inspect.py b/core/tests/test_inspect.py new file mode 100644 index 000000000..0baf0dc5d --- /dev/null +++ b/core/tests/test_inspect.py @@ -0,0 +1,249 @@ +from typing import Any + +import pytest + +from testcontainers.core.container import DockerContainer +from testcontainers.core.docker_client import DockerClient +from testcontainers.core.inspect import ContainerInspectInfo +from testcontainers.core.config import ConnectionMode + +FAKE_ID = "ABC123" + + +class FakeContainer: + def __init__(self) -> None: + self.attrs: dict[str, Any] = {} + + @property + def id(self) -> str: + return FAKE_ID + + +@pytest.fixture +def container(monkeypatch: pytest.MonkeyPatch) -> DockerContainer: + """ + Fake initialized container + """ + client = DockerClient() + container = DockerContainer("foobar") + monkeypatch.setattr(container, "_docker", client) + monkeypatch.setattr(container, "_container", FakeContainer()) + + return container + + +def test_get_container_info_returns_none_when_no_container( + container: DockerContainer, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test get_container_info returns None when container is not started.""" + monkeypatch.setattr(container, "_container", None) + info = container.get_container_info() + assert info is None + + +def test_get_container_info_lazy_loading(container: DockerContainer, monkeypatch: pytest.MonkeyPatch) -> None: + """Test get_container_info lazy loading and caching.""" + fake_data = {"Id": "test123", "Name": "/test-container", "Image": "nginx:alpine"} + fake_info = ContainerInspectInfo.from_dict(fake_data) + + monkeypatch.setattr(container._docker, "get_container_inspect_info", lambda _: fake_info) + + info1 = container.get_container_info() + assert info1 is not None + assert info1.Id == "test123" + assert info1.Name == "/test-container" + assert info1.Image == "nginx:alpine" + + info2 = container.get_container_info() + assert info1 is info2 + + +def test_get_container_info_structure(container: DockerContainer, monkeypatch: pytest.MonkeyPatch) -> None: + """Test get_container_info returns properly structured data.""" + fake_data = { + "Id": "abc123def456", + "Name": "/my-test-container", + "Image": "sha256:nginx123", + "Created": "2023-01-01T00:00:00Z", + "State": { + "Status": "running", + "Running": True, + "Pid": 5678, + "ExitCode": 0, + "Health": {"Status": "healthy", "FailingStreak": 0, "Log": [{"Output": "healthy"}]}, + }, + "Config": { + "Image": "nginx:alpine", + "Hostname": "my-hostname", + "Env": ["PATH=/usr/bin", "HOME=/root"], + "Cmd": ["nginx", "-g", "daemon off;"], + "ExposedPorts": {"80/tcp": {}}, + }, + "NetworkSettings": { + "IPAddress": "172.17.0.3", + "Gateway": "172.17.0.1", + "Networks": { + "bridge": { + "IPAddress": "172.17.0.3", + "Gateway": "172.17.0.1", + "NetworkID": "net123", + "MacAddress": "02:42:ac:11:00:03", + "Aliases": ["container-alias"], + } + }, + }, + "HostConfig": {"Memory": 1073741824, "CpuShares": 1024, "NetworkMode": "bridge"}, + } + fake_info = ContainerInspectInfo.from_dict(fake_data) + + monkeypatch.setattr(container._docker, "get_container_inspect_info", lambda _: fake_info) + + info = container.get_container_info() + assert info is not None + + assert info.Id == "abc123def456" + assert info.Name == "/my-test-container" + assert info.Image == "sha256:nginx123" + assert info.Created == "2023-01-01T00:00:00Z" + + assert info.State is not None + assert info.State.Status == "running" + assert info.State.Running is True + assert info.State.Pid == 5678 + assert info.State.ExitCode == 0 + assert info.State.Health is not None + assert info.State.Health.Status == "healthy" + assert info.State.Health.FailingStreak == 0 + assert info.State.Health.Log is not None + assert len(info.State.Health.Log) == 1 + assert info.State.Health.Log[0].Output == "healthy" + + assert info.Config is not None + assert info.Config.Image == "nginx:alpine" + assert info.Config.Hostname == "my-hostname" + assert info.Config.Env == ["PATH=/usr/bin", "HOME=/root"] + assert info.Config.Cmd == ["nginx", "-g", "daemon off;"] + assert info.Config.ExposedPorts == {"80/tcp": {}} + + network_settings = info.get_network_settings() + assert network_settings is not None + assert network_settings.IPAddress == "172.17.0.3" + assert network_settings.Gateway == "172.17.0.1" + + assert network_settings.Networks is not None + assert "bridge" in network_settings.Networks + bridge_network = network_settings.Networks["bridge"] + assert bridge_network.IPAddress == "172.17.0.3" + assert bridge_network.Gateway == "172.17.0.1" + assert bridge_network.NetworkID == "net123" + assert bridge_network.MacAddress == "02:42:ac:11:00:03" + assert bridge_network.Aliases == ["container-alias"] + + assert info.HostConfig is not None + assert info.HostConfig.Memory == 1073741824 + assert info.HostConfig.CpuShares == 1024 + assert info.HostConfig.NetworkMode == "bridge" + + +def test_get_container_info_handles_exceptions(container: DockerContainer, monkeypatch: pytest.MonkeyPatch) -> None: + """Test get_container_info handles exceptions gracefully.""" + + def mock_exception(_): + raise Exception("Docker API error") + + monkeypatch.setattr(container._docker, "get_container_inspect_info", mock_exception) + + info = container.get_container_info() + assert info is None + + +def test_get_container_info_with_none_values(container: DockerContainer, monkeypatch: pytest.MonkeyPatch) -> None: + """Test get_container_info handles None values in HostConfig and NetworkSettings.""" + fake_data = { + "Id": "test-none-values", + "Name": "/test-none", + "Image": "nginx:alpine", + "NetworkSettings": {"IPAddress": "172.17.0.2", "Networks": None, "Ports": None}, + "HostConfig": {"Memory": 0, "NetworkMode": "bridge", "PortBindings": None}, + } + fake_info = ContainerInspectInfo.from_dict(fake_data) + + monkeypatch.setattr(container._docker, "get_container_inspect_info", lambda _: fake_info) + + info = container.get_container_info() + assert info is not None + assert info.Id == "test-none-values" + + network_settings = info.get_network_settings() + assert network_settings is not None + assert network_settings.IPAddress == "172.17.0.2" + assert network_settings.Networks is None + assert network_settings.Ports is None + + assert info.HostConfig is not None + assert info.HostConfig.Memory == 0 + assert info.HostConfig.NetworkMode == "bridge" + assert info.HostConfig.PortBindings is None + + +def test_get_container_info_with_port_bindings(container: DockerContainer, monkeypatch: pytest.MonkeyPatch) -> None: + """Test get_container_info handles port bindings correctly.""" + fake_data = { + "Id": "test-port-bindings", + "Name": "/test-ports", + "Image": "nginx:alpine", + "NetworkSettings": {"Ports": {"80/tcp": [{"HostPort": "8080"}], "443/tcp": None}}, + "HostConfig": {"NetworkMode": "bridge", "PortBindings": {"80/tcp": [{"HostPort": "8080"}], "443/tcp": None}}, + } + fake_info = ContainerInspectInfo.from_dict(fake_data) + + monkeypatch.setattr(container._docker, "get_container_inspect_info", lambda _: fake_info) + + info = container.get_container_info() + assert info is not None + + network_settings = info.get_network_settings() + assert network_settings is not None + assert network_settings.Ports is not None + assert "80/tcp" in network_settings.Ports + port_bindings = network_settings.Ports["80/tcp"] + assert port_bindings is not None + assert len(port_bindings) == 1 + assert port_bindings[0].HostPort == "8080" + assert network_settings.Ports["443/tcp"] is None + + assert info.HostConfig is not None + assert info.HostConfig.PortBindings is not None + assert "80/tcp" in info.HostConfig.PortBindings + host_port_bindings = info.HostConfig.PortBindings["80/tcp"] + assert host_port_bindings is not None + assert len(host_port_bindings) == 1 + assert host_port_bindings[0].HostPort == "8080" + assert info.HostConfig.PortBindings["443/tcp"] is None + + +def test_get_container_info_edge_cases_regression(container: DockerContainer, monkeypatch: pytest.MonkeyPatch) -> None: + """Regression test for None value handling.""" + fake_data = { + "Id": "regression-test", + "Name": "/regression-container", + "Image": "nginx:alpine", + "NetworkSettings": {"IPAddress": "172.17.0.2", "Networks": None, "Ports": None}, + "HostConfig": {"Memory": 0, "NetworkMode": "bridge", "PortBindings": None}, + } + fake_info = ContainerInspectInfo.from_dict(fake_data) + + monkeypatch.setattr(container._docker, "get_container_inspect_info", lambda _: fake_info) + + info = container.get_container_info() + assert info is not None + assert info.Id == "regression-test" + + network_settings = info.get_network_settings() + assert network_settings is not None + assert network_settings.Networks is None + assert network_settings.Ports is None + + host_config = info.HostConfig + assert host_config is not None + assert host_config.PortBindings is None diff --git a/core/tests/test_transferable.py b/core/tests/test_transferable.py index 992f163af..592ad87df 100644 --- a/core/tests/test_transferable.py +++ b/core/tests/test_transferable.py @@ -104,6 +104,19 @@ def test_copy_into_container_at_startup(transferable: Transferable): assert result.output == b"hello world" +def test_copy_into_startup_file(transferable: Transferable): + destination_in_container = "/tmp/my_file" + + container = DockerContainer("bash", command=f"cat {destination_in_container}") + container.with_copy_into_container(transferable, destination_in_container) + + with container: + exit_code = container.wait() + stdout, _ = container.get_logs() + assert exit_code == 0 + assert stdout.decode() == "hello world" + + def test_copy_into_container_via_initializer(transferable: Transferable): destination_in_container = "/tmp/my_file" transferables: list[TransferSpec] = [(transferable, destination_in_container, 0o644)] diff --git a/docs/features/creating_container.md b/docs/features/creating_container.md index fa3b1190b..40c632943 100644 --- a/docs/features/creating_container.md +++ b/docs/features/creating_container.md @@ -128,6 +128,62 @@ def test_with_nginx(nginx_container): For details on waiting for containers to be ready, see [Wait strategies](wait_strategies.md). +## Container Information + +You can get detailed information about containers using the `get_container_info()` method. This works with both `DockerContainer` and `ComposeContainer`: + +```python +from testcontainers.generic import GenericContainer + +def test_container_info(): + with GenericContainer("nginx:alpine") as container: + # Get detailed container information + info = container.get_container_info() + + if info: + # Basic container information + print(f"Container ID: {info.Id}") + print(f"Name: {info.Name}") + print(f"Image: {info.Image}") + + # Container state + if info.State: + print(f"Status: {info.State.Status}") + print(f"Running: {info.State.Running}") + print(f"PID: {info.State.Pid}") + print(f"Exit Code: {info.State.ExitCode}") + + # Container configuration + if info.Config: + print(f"Hostname: {info.Config.Hostname}") + print(f"Environment: {info.Config.Env}") + print(f"Command: {info.Config.Cmd}") + + # Network information + network_settings = info.get_network_settings() + if network_settings and network_settings.Networks: + for network_name, network in network_settings.Networks.items(): + print(f"Network: {network_name}") + print(f" IP Address: {network.IPAddress}") + print(f" Gateway: {network.Gateway}") + print(f" MAC Address: {network.MacAddress}") +``` + +The container information is lazy-loaded and cached, so subsequent calls to `get_container_info()` will return the same data without making additional Docker API calls. + +### Available Information + +The `ContainerInspectInfo` object provides structured access to all Docker Engine API fields: + +- **Basic Info**: Container ID, name, image, creation time, platform +- **State**: Running status, PID, exit code, start/finish times, health status +- **Config**: Environment variables, command, working directory, labels, exposed ports +- **Network**: IP addresses, port bindings, network configurations, aliases +- **Host Config**: Memory limits, CPU settings, device mappings, restart policies +- **Mounts**: Volume and bind mount information with detailed options +- **Health**: Health check status and logs (if configured) +- **Platform**: Architecture and OS information + ## Best Practices 1. Always use context managers or ensure proper cleanup diff --git a/docs/features/docker_compose.md b/docs/features/docker_compose.md index 006a12b92..6b874a348 100644 --- a/docs/features/docker_compose.md +++ b/docs/features/docker_compose.md @@ -60,6 +60,22 @@ with DockerCompose("path/to/compose/directory") as compose: # Get container logs stdout, stderr = compose.get_logs("web") + + # Get detailed container information + container = compose.get_container("web") + info = container.get_container_info() + if info: + print(f"Container ID: {info.Id}") + if info.State: + print(f"Status: {info.State.Status}") + if info.Config: + print(f"Image: {info.Config.Image}") + + # Access network settings + network_settings = info.get_network_settings() + if network_settings and network_settings.Networks: + for name, network in network_settings.Networks.items(): + print(f"Network {name}: IP {network.IPAddress}") ``` ## Waiting for Services @@ -105,6 +121,46 @@ def test_web_application(): assert exit_code == 0 ``` +## Container Information + +You can get detailed information about containers using the `get_container_info()` method: + +```python +with DockerCompose("path/to/compose/directory") as compose: + container = compose.get_container("web") + info = container.get_container_info() + + if info: + # Basic container information + print(f"Container ID: {info.Id}") + print(f"Name: {info.Name}") + print(f"Image: {info.Image}") + + # Container state + if info.State: + print(f"Status: {info.State.Status}") + print(f"Running: {info.State.Running}") + print(f"PID: {info.State.Pid}") + print(f"Exit Code: {info.State.ExitCode}") + + # Container configuration + if info.Config: + print(f"Hostname: {info.Config.Hostname}") + print(f"Environment: {info.Config.Env}") + print(f"Command: {info.Config.Cmd}") + + # Network information + network_settings = info.get_network_settings() + if network_settings and network_settings.Networks: + for network_name, network in network_settings.Networks.items(): + print(f"Network: {network_name}") + print(f" IP Address: {network.IPAddress}") + print(f" Gateway: {network.Gateway}") + print(f" MAC Address: {network.MacAddress}") +``` + +The container information is lazy-loaded and cached, so subsequent calls to `get_container_info()` will return the same data without making additional Docker API calls. + ## Best Practices 1. Use context managers (`with` statement) to ensure proper cleanup diff --git a/docs/modules/temporal.md b/docs/modules/temporal.md new file mode 100644 index 000000000..ac960b86e --- /dev/null +++ b/docs/modules/temporal.md @@ -0,0 +1,29 @@ +# Temporal + +## Introduction + +The Testcontainers module for [Temporal](https://temporal.io/) — a durable execution platform for running reliable, long-running workflows. + +This module spins up the Temporal dev server (`temporalio/temporal`) which includes the Temporal server, a preconfigured `default` namespace, and the Web UI. + +## Adding this module to your project dependencies + +Please run the following command to add the Temporal module to your python dependencies: + +```bash +pip install testcontainers[temporal] +``` + +To interact with the server you will also need a Temporal SDK, for example: + +```bash +pip install temporalio +``` + +## Usage example + + + +[Creating a Temporal container](../../modules/temporal/example_basic.py) + + diff --git a/docs/modules/valkey.md b/docs/modules/valkey.md new file mode 100644 index 000000000..d71182fac --- /dev/null +++ b/docs/modules/valkey.md @@ -0,0 +1,23 @@ +# Valkey + +Since testcontainers-python :material-tag: v4.14.3 + +## Introduction + +The Testcontainers module for Valkey. + +## Adding this module to your project dependencies + +Please run the following command to add the Valkey module to your python dependencies: + +```bash +pip install testcontainers[valkey] +``` + +## Usage example + + + +[Creating a Valkey container](../../modules/valkey/example_basic.py) + + diff --git a/mkdocs.yml b/mkdocs.yml index aca8281b7..305aed7c8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -72,6 +72,7 @@ nav: - modules/redis.md - modules/scylla.md - modules/trino.md + - modules/valkey.md - modules/weaviate.md - modules/aws.md - modules/azurite.md @@ -93,6 +94,7 @@ nav: - modules/registry.md - modules/selenium.md - modules/sftp.md + - modules/temporal.md - modules/test_module_import.md - modules/vault.md - System Requirements: diff --git a/modules/azurite/testcontainers/azurite/__init__.py b/modules/azurite/testcontainers/azurite/__init__.py index f4e76d670..3cd755f34 100644 --- a/modules/azurite/testcontainers/azurite/__init__.py +++ b/modules/azurite/testcontainers/azurite/__init__.py @@ -12,12 +12,11 @@ # under the License. import enum import os -import socket from typing import Optional from testcontainers.core.container import DockerContainer from testcontainers.core.utils import raise_for_deprecated_parameter -from testcontainers.core.waiting_utils import wait_container_is_ready +from testcontainers.core.wait_strategies import PortWaitStrategy class ConnectionStringType(enum.Enum): @@ -223,7 +222,6 @@ def start(self) -> "AzuriteContainer": self._connect() return self - @wait_container_is_ready(OSError) def _connect(self) -> None: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.connect((self.get_container_host_ip(), int(self.get_exposed_port(next(iter(self.ports)))))) + strategy = PortWaitStrategy(int(next(iter(self.ports)))) + strategy.wait_until_ready(self) diff --git a/modules/azurite/testcontainers/azurite/py.typed b/modules/azurite/testcontainers/azurite/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/modules/clickhouse/testcontainers/clickhouse/__init__.py b/modules/clickhouse/testcontainers/clickhouse/__init__.py index fbc8fab65..00ebde809 100644 --- a/modules/clickhouse/testcontainers/clickhouse/__init__.py +++ b/modules/clickhouse/testcontainers/clickhouse/__init__.py @@ -12,12 +12,10 @@ # under the License. import os from typing import Optional -from urllib.error import HTTPError, URLError -from urllib.request import urlopen from testcontainers.core.generic import DbContainer from testcontainers.core.utils import raise_for_deprecated_parameter -from testcontainers.core.waiting_utils import wait_container_is_ready +from testcontainers.core.wait_strategies import HttpWaitStrategy class ClickHouseContainer(DbContainer): @@ -58,12 +56,9 @@ def __init__( self.with_exposed_ports(self.port) self.with_exposed_ports(8123) - @wait_container_is_ready(HTTPError, URLError) def _connect(self) -> None: - # noinspection HttpUrlsUsage - url = f"http://{self.get_container_host_ip()}:{self.get_exposed_port(8123)}" - with urlopen(url) as r: - assert b"Ok" in r.read() + strategy = HttpWaitStrategy(8123).for_response_predicate(lambda response: "Ok" in response) + strategy.wait_until_ready(self) def _configure(self) -> None: self.with_env("CLICKHOUSE_USER", self.username) diff --git a/modules/mongodb/README.rst b/modules/mongodb/README.rst index 37e836406..4c2af683e 100644 --- a/modules/mongodb/README.rst +++ b/modules/mongodb/README.rst @@ -1,2 +1,3 @@ .. autoclass:: testcontainers.mongodb.MongoDbContainer +.. autoclass:: testcontainers.mongodb.MongoDBAtlasLocalContainer .. title:: testcontainers.mongodb.MongoDbContainer diff --git a/modules/mongodb/testcontainers/mongodb/__init__.py b/modules/mongodb/testcontainers/mongodb/__init__.py index 7ab4e11d4..43818d816 100644 --- a/modules/mongodb/testcontainers/mongodb/__init__.py +++ b/modules/mongodb/testcontainers/mongodb/__init__.py @@ -18,6 +18,7 @@ from testcontainers.core.generic import DbContainer from testcontainers.core.utils import raise_for_deprecated_parameter +from testcontainers.core.wait_strategies import HealthcheckWaitStrategy from testcontainers.core.waiting_utils import wait_for_logs @@ -87,3 +88,94 @@ def predicate(text: str) -> bool: def get_connection_client(self) -> MongoClient: return MongoClient(self.get_connection_url()) + + +class MongoDBAtlasLocalContainer(DbContainer): + """ + MongoDB Atlas Local document-based database container. + + This is the local version of the Mongo Atlas service. + It includes Mongo DB and Mongo Atlas Search services + Example: + + .. doctest:: + + >>> from testcontainers.mongodb import MongoDBAtlasLocalContainer + >>> import time + >>> with MongoDBAtlasLocalContainer("mongodb/mongodb-atlas-local:8.0.13") as mongo: + ... db = mongo.get_connection_client().test + ... # Insert a database entry + ... result = db.restaurants.insert_one( + ... { + ... "name": "Vella", + ... "cuisine": "Italian", + ... "restaurant_id": "123456" + ... } + ... ) + ... # add an index + ... _ = db.restaurants.create_search_index( + ... { + ... "definition": { + ... "mappings": { + ... "dynamic": True + ... } + ... }, + ... "name": "default" + ... } + ... ) + ... # wait for the index to be created + ... time.sleep(1) + ... + ... # Find the restaurant document + ... result = db.restaurants.aggregate([{ + ... "$search": { + ... "index": "default", + ... "text": { + ... "query": "Vella", + ... "path": "name" + ... } + ... } + ... }]).next() + ... result["restaurant_id"] + '123456' + """ + + def __init__( + self, + image: str = "mongodb/mongodb-atlas-local:latest", + port: int = 27017, + username: Optional[str] = None, + password: Optional[str] = None, + dbname: Optional[str] = None, + **kwargs, + ) -> None: + raise_for_deprecated_parameter(kwargs, "port_to_expose", "port") + super().__init__(image=image, **kwargs) + self.username = username if username else os.environ.get("MONGODB_INITDB_ROOT_USERNAME", "test") + self.password = password if password else os.environ.get("MONGODB_INITDB_ROOT_PASSWORD", "test") + self.dbname = dbname if dbname else os.environ.get("MONGODB_INITDB_DATABASE", "test") + self.port = port + self.with_exposed_ports(self.port) + + def _configure(self) -> None: + self.with_env("MONGODB_INITDB_ROOT_USERNAME", self.username) + self.with_env("MONGODB_INITDB_ROOT_PASSWORD", self.password) + self.with_env("MONGODB_INITDB_DATABASE", self.dbname) + + def get_connection_url(self) -> str: + return ( + self._create_connection_url( + dialect="mongodb", + username=self.username, + password=self.password, + port=self.port, + ) + + "?directConnection=true" + ) + + def _connect(self) -> None: + strategy = HealthcheckWaitStrategy() + strategy.wait_until_ready(self) + + def get_connection_client(self) -> MongoClient: + return MongoClient(self.get_connection_url()) diff --git a/modules/mongodb/tests/test_mongodb.py b/modules/mongodb/tests/test_mongodb.py index 9bf3600f2..352c4a709 100644 --- a/modules/mongodb/tests/test_mongodb.py +++ b/modules/mongodb/tests/test_mongodb.py @@ -1,8 +1,9 @@ +import time import pytest from pymongo import MongoClient from pymongo.errors import OperationFailure -from testcontainers.mongodb import MongoDbContainer +from testcontainers.mongodb import MongoDbContainer, MongoDBAtlasLocalContainer @pytest.mark.parametrize("version", ["7.0.7", "6.0.14", "5.0.26"]) @@ -28,6 +29,53 @@ def test_docker_run_mongodb(version: str): assert cursor.next()["restaurant_id"] == doc["restaurant_id"] +@pytest.mark.parametrize("version", ["8.0.13", "7.0.23"]) +def test_docker_run_mongodb_atlas_local(version: str): + with MongoDBAtlasLocalContainer(f"mongodb/mongodb-atlas-local:{version}") as mongo_atlas: + db = mongo_atlas.get_connection_client().test + index_doc = { + "definition": { + "mappings": { + "dynamic": False, + "fields": { + "borough": {"analyzer": "lucene.keyword", "type": "string"}, + }, + }, + }, + "name": "test", + } + + db.create_collection("restaurants") + + db.restaurants.create_search_index(index_doc) + + doc = { + "address": { + "street": "2 Avenue", + "zipcode": "10075", + "building": "1480", + "coord": [-73.9557413, 40.7720266], + }, + "borough": "Manhattan", + "cuisine": "Italian", + "name": "Vella", + "restaurant_id": "41704620", + } + result = db.restaurants.insert_one(doc) + assert result.inserted_id + + # Wait for index to catch up + indexes = db.restaurants.list_search_indexes() + while indexes.next()["status"] != "READY": + time.sleep(0.1) + indexes = db.restaurants.list_search_indexes() + + cursor = db.restaurants.aggregate( + [{"$search": {"index": "test", "text": {"query": "Manhattan", "path": "borough"}}}] + ) + assert cursor.next()["restaurant_id"] == doc["restaurant_id"] + + # This is a feature in the generic DbContainer class # but it can't be tested on its own # so is tested in various database modules: diff --git a/modules/mqtt/testcontainers/mqtt/__init__.py b/modules/mqtt/testcontainers/mqtt/__init__.py index 854ec21f8..8321d0d06 100644 --- a/modules/mqtt/testcontainers/mqtt/__init__.py +++ b/modules/mqtt/testcontainers/mqtt/__init__.py @@ -121,7 +121,7 @@ def start(self, configfile: Optional[str] = None) -> Self: # default config file configfile = Path(__file__).parent / MosquittoContainer.CONFIG_FILE self.with_volume_mapping(configfile, "/mosquitto/config/mosquitto.conf") - # since version 2.1.1 - 2026-02-04, which fixed a PUID/PGID issue, the container needs to write to the data directory, + # since version 2.1.1 - 2026-02-04, which fixed a PUID/PGID issue, the container needs to write to the data directory, # so we mount it as tmpfs for better performance in tests self.with_tmpfs_mount("/data") diff --git a/modules/postgres/testcontainers/postgres/py.typed b/modules/postgres/testcontainers/postgres/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/modules/qdrant/testcontainers/qdrant/__init__.py b/modules/qdrant/testcontainers/qdrant/__init__.py index 3b77b50fd..b2a2e8df2 100644 --- a/modules/qdrant/testcontainers/qdrant/__init__.py +++ b/modules/qdrant/testcontainers/qdrant/__init__.py @@ -15,12 +15,11 @@ from pathlib import Path from typing import Optional -from testcontainers.core.config import testcontainers_config as c -from testcontainers.core.generic import DbContainer -from testcontainers.core.waiting_utils import wait_container_is_ready, wait_for_logs +from testcontainers.core.container import DockerContainer +from testcontainers.core.wait_strategies import LogMessageWaitStrategy -class QdrantContainer(DbContainer): +class QdrantContainer(DockerContainer): """ Qdrant vector database container. @@ -39,7 +38,7 @@ class QdrantContainer(DbContainer): def __init__( self, - image: str = "qdrant/qdrant:v1.13.5", + image: str = "qdrant/qdrant:v1.16.2", rest_port: int = 6333, grpc_port: int = 6334, api_key: Optional[str] = None, @@ -59,9 +58,8 @@ def __init__( def _configure(self) -> None: self.with_env("QDRANT__SERVICE__API_KEY", self._api_key) - @wait_container_is_ready() def _connect(self) -> None: - wait_for_logs(self, ".*Actix runtime found; starting in Actix runtime.*", c.timeout) + LogMessageWaitStrategy(".*Actix runtime found; starting in Actix runtime.*").wait_until_ready(self) def get_client(self, **kwargs): """ diff --git a/modules/redis/testcontainers/redis/__init__.py b/modules/redis/testcontainers/redis/__init__.py index 7a4d46613..24895b328 100644 --- a/modules/redis/testcontainers/redis/__init__.py +++ b/modules/redis/testcontainers/redis/__init__.py @@ -17,7 +17,7 @@ from redis.asyncio import Redis as asyncRedis from testcontainers.core.container import DockerContainer from testcontainers.core.utils import raise_for_deprecated_parameter -from testcontainers.core.waiting_utils import wait_container_is_ready +from testcontainers.core.waiting_utils import WaitStrategy, WaitStrategyTarget class RedisContainer(DockerContainer): @@ -36,19 +36,13 @@ class RedisContainer(DockerContainer): def __init__(self, image: str = "redis:latest", port: int = 6379, password: Optional[str] = None, **kwargs) -> None: raise_for_deprecated_parameter(kwargs, "port_to_expose", "port") - super().__init__(image, **kwargs) + super().__init__(image, _wait_strategy=PingWaitStrategy(), **kwargs) self.port = port self.password = password self.with_exposed_ports(self.port) if self.password: self.with_command(f"redis-server --requirepass {self.password}") - @wait_container_is_ready(redis.exceptions.ConnectionError) - def _connect(self) -> None: - client = self.get_client() - if not client.ping(): - raise redis.exceptions.ConnectionError("Could not connect to Redis") - def get_client(self, **kwargs) -> redis.Redis: """ Get a redis client. @@ -66,10 +60,15 @@ def get_client(self, **kwargs) -> redis.Redis: **kwargs, ) - def start(self) -> "RedisContainer": - super().start() - self._connect() - return self + +class PingWaitStrategy(WaitStrategy): + def __init__(self) -> None: + super().__init__() + self.with_transient_exceptions(redis.exceptions.ConnectionError) + + def wait_until_ready(self, container: WaitStrategyTarget) -> None: + if not self._poll(lambda: container.get_client().ping()): + raise redis.exceptions.ConnectionError("Could not connect to Redis") class AsyncRedisContainer(RedisContainer): diff --git a/modules/redis/tests/test_redis.py b/modules/redis/tests/test_redis.py index bd8e244c5..01be35f14 100644 --- a/modules/redis/tests/test_redis.py +++ b/modules/redis/tests/test_redis.py @@ -2,6 +2,7 @@ from testcontainers.redis import RedisContainer, AsyncRedisContainer import pytest +import redis def test_docker_run_redis(): @@ -24,6 +25,16 @@ def test_docker_run_redis_with_password(): assert client.get("hello") == "world" +def test_docker_run_start_fails(monkeypatch: pytest.MonkeyPatch): + # Patch config to speed up the test. + monkeypatch.setattr("testcontainers.core.config.testcontainers_config.max_tries", 0.3) + monkeypatch.setattr("testcontainers.core.config.testcontainers_config.sleep_time", 0.02) + # Use a bogus image to make the startup check fail. + config = RedisContainer(image="hello-world") + with pytest.raises(redis.exceptions.ConnectionError, match="Could not connect"): + config.start() + + pytest.mark.usefixtures("anyio_backend") diff --git a/modules/temporal/README.rst b/modules/temporal/README.rst new file mode 100644 index 000000000..f9ac1eb3f --- /dev/null +++ b/modules/temporal/README.rst @@ -0,0 +1,2 @@ +.. autoclass:: testcontainers.temporal.TemporalContainer +.. title:: testcontainers.temporal.TemporalContainer diff --git a/modules/temporal/example_basic.py b/modules/temporal/example_basic.py new file mode 100644 index 000000000..86258a29b --- /dev/null +++ b/modules/temporal/example_basic.py @@ -0,0 +1,40 @@ +import asyncio +from datetime import timedelta + +from temporalio.api.workflowservice.v1 import ListNamespacesRequest +from temporalio.client import Client + +from testcontainers.temporal import TemporalContainer + + +async def main(): + with TemporalContainer() as temporal: + print(f"Temporal gRPC address: {temporal.get_grpc_address()}") + print(f"Temporal Web UI: {temporal.get_web_ui_url()}") + + # Connect a Temporal client + client = await Client.connect(temporal.get_grpc_address()) + + # List available namespaces + resp = await client.service_client.workflow_service.list_namespaces(ListNamespacesRequest()) + for ns in resp.namespaces: + print(f"Namespace: {ns.namespace_info.name}") + + # Start a workflow (untyped — no workflow definition class needed) + handle = await client.start_workflow( + "GreetingWorkflow", + id="greeting-wf-1", + task_queue="greeting-queue", + execution_timeout=timedelta(seconds=10), + memo={"env": "example"}, + ) + print(f"Started workflow: {handle.id}") + + # Describe the workflow + desc = await handle.describe() + print(f"Workflow type: {desc.workflow_type}") + print(f"Task queue: {desc.task_queue}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/modules/temporal/testcontainers/temporal/__init__.py b/modules/temporal/testcontainers/temporal/__init__.py new file mode 100644 index 000000000..3e8be903e --- /dev/null +++ b/modules/temporal/testcontainers/temporal/__init__.py @@ -0,0 +1,54 @@ +import urllib.error +import urllib.parse +import urllib.request + +from testcontainers.core.container import DockerContainer +from testcontainers.core.waiting_utils import wait_container_is_ready + + +class TemporalContainer(DockerContainer): + """Temporal dev server container for integration testing. + + Example: + + The example spins up a Temporal dev server and connects to it using the + ``temporalio`` Python SDK. + + .. doctest:: + + >>> from testcontainers.temporal import TemporalContainer + >>> with TemporalContainer() as temporal: + ... address = temporal.get_grpc_address() + """ + + GRPC_PORT = 7233 + HTTP_PORT = 8233 + + def __init__(self, image: str = "temporalio/temporal:1.5.1", **kwargs) -> None: + super().__init__(image, **kwargs) + self.with_exposed_ports(self.GRPC_PORT, self.HTTP_PORT) + self.with_command("server start-dev --ip 0.0.0.0") + + @wait_container_is_ready(urllib.error.URLError, ConnectionError) + def _healthcheck(self) -> None: + host = self.get_container_host_ip() + port = self.get_exposed_port(self.HTTP_PORT) + url = urllib.parse.urlunsplit(("http", f"{host}:{port}", "/api/v1/namespaces", "", "")) + urllib.request.urlopen(url, timeout=1) + + def start(self) -> "TemporalContainer": + super().start() + self._healthcheck() + return self + + def get_grpc_address(self) -> str: + """Returns ``host:port`` for the Temporal gRPC frontend. + + The address intentionally omits a scheme because the Temporal SDKs + expect a plain ``host:port`` string. + """ + return f"{self.get_container_host_ip()}:{self.get_exposed_port(self.GRPC_PORT)}" + + def get_web_ui_url(self) -> str: + """Returns the base URL for the Temporal Web UI / HTTP API.""" + return f"http://{self.get_container_host_ip()}:{self.get_exposed_port(self.HTTP_PORT)}" diff --git a/modules/temporal/tests/test_temporal.py b/modules/temporal/tests/test_temporal.py new file mode 100644 index 000000000..067439b4f --- /dev/null +++ b/modules/temporal/tests/test_temporal.py @@ -0,0 +1,42 @@ +from datetime import timedelta +from uuid import uuid4 + +import pytest +from temporalio.api.workflowservice.v1 import ListNamespacesRequest +from temporalio.client import Client + +from testcontainers.temporal import TemporalContainer + + +@pytest.fixture(scope="module") +def temporal_container(): + with TemporalContainer() as container: + yield container + + +@pytest.mark.asyncio +async def test_default_namespace_exists(temporal_container): + client = await Client.connect(temporal_container.get_grpc_address()) + resp = await client.service_client.workflow_service.list_namespaces(ListNamespacesRequest()) + names = [ns.namespace_info.name for ns in resp.namespaces] + assert "default" in names + + +@pytest.mark.asyncio +async def test_start_and_describe_workflow(temporal_container): + client = await Client.connect(temporal_container.get_grpc_address()) + workflow_id = str(uuid4()) + + handle = await client.start_workflow( + "MyWorkflow", + id=workflow_id, + task_queue="my-task-queue", + execution_timeout=timedelta(seconds=10), + memo={"env": "test"}, + ) + desc = await handle.describe() + assert desc.id == workflow_id + assert desc.workflow_type == "MyWorkflow" + assert desc.task_queue == "my-task-queue" + memo = await desc.memo() + assert memo is not None diff --git a/modules/valkey/README.rst b/modules/valkey/README.rst new file mode 100644 index 000000000..abe0c74e1 --- /dev/null +++ b/modules/valkey/README.rst @@ -0,0 +1,2 @@ +.. autoclass:: testcontainers.valkey.ValkeyContainer +.. title:: testcontainers.valkey.ValkeyContainer diff --git a/modules/valkey/example_basic.py b/modules/valkey/example_basic.py new file mode 100644 index 000000000..1288b5d94 --- /dev/null +++ b/modules/valkey/example_basic.py @@ -0,0 +1,82 @@ +""" +Valkey container usage examples with valkey-glide sync client. + +Requires: pip install valkey-glide-sync +""" + +from glide_sync import GlideClient, GlideClientConfiguration, NodeAddress, ServerCredentials + +from testcontainers.valkey import ValkeyContainer + + +def basic_example(): + with ValkeyContainer() as valkey_container: + host = valkey_container.get_host() + port = valkey_container.get_exposed_port() + connection_url = valkey_container.get_connection_url() + + print(f"Valkey connection URL: {connection_url}") + print(f"Host: {host}, Port: {port}") + + config = GlideClientConfiguration([NodeAddress(host, port)]) + client = GlideClient.create(config) + + pong = client.ping() + print(f"PING response: {pong}") + + client.set("key", "value") + print("SET response: OK") + + value = client.get("key") + print(f"GET response: {value}") + + client.close() + + +def password_example(): + with ValkeyContainer().with_password("mypassword") as valkey_container: + host = valkey_container.get_host() + port = valkey_container.get_exposed_port() + connection_url = valkey_container.get_connection_url() + + print(f"\nValkey with password connection URL: {connection_url}") + + config = GlideClientConfiguration( + [NodeAddress(host, port)], + credentials=ServerCredentials(password="mypassword"), + ) + client = GlideClient.create(config) + + pong = client.ping() + print(f"PING response: {pong}") + + client.close() + + +def version_example(): + with ValkeyContainer().with_image_tag("8.0") as valkey_container: + print(f"\nUsing image: {valkey_container.image}") + connection_url = valkey_container.get_connection_url() + print(f"Connection URL: {connection_url}") + + +def bundle_example(): + with ValkeyContainer().with_bundle() as valkey_container: + print(f"\nUsing bundle image: {valkey_container.image}") + host = valkey_container.get_host() + port = valkey_container.get_exposed_port() + + config = GlideClientConfiguration([NodeAddress(host, port)]) + client = GlideClient.create(config) + + pong = client.ping() + print(f"PING response: {pong}") + + client.close() + + +if __name__ == "__main__": + basic_example() + password_example() + version_example() + bundle_example() diff --git a/modules/valkey/testcontainers/valkey/__init__.py b/modules/valkey/testcontainers/valkey/__init__.py new file mode 100644 index 000000000..7237a64fe --- /dev/null +++ b/modules/valkey/testcontainers/valkey/__init__.py @@ -0,0 +1,103 @@ +# +# 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 testcontainers.core.container import DockerContainer +from testcontainers.core.wait_strategies import ExecWaitStrategy + +_BASE_IMAGE = "valkey/valkey" +_BUNDLE_IMAGE = "valkey/valkey-bundle" + + +class ValkeyContainer(DockerContainer): + """ + Valkey container. + + """ + + def __init__(self, image: str = f"{_BASE_IMAGE}:latest", port: int = 6379, **kwargs) -> None: + super().__init__(image, **kwargs) + self.port = port + self.password: str | None = None + self.with_exposed_ports(self.port) + self.waiting_for(ExecWaitStrategy(["valkey-cli", "ping"])) + + def with_password(self, password: str) -> "ValkeyContainer": + """ + Configure authentication for Valkey. + + Args: + password: Password for Valkey authentication. + + Returns: + self: Container instance for method chaining. + """ + self.password = password + self.with_command(["valkey-server", "--requirepass", password]) + self.waiting_for(ExecWaitStrategy(["valkey-cli", "-a", password, "ping"])) + return self + + def with_image_tag(self, tag: str) -> "ValkeyContainer": + """ + Specify Valkey version. + + Args: + tag: Image tag (e.g., '8.0', 'latest'). + + Returns: + self: Container instance for method chaining. + """ + base_image = self.image.rsplit(":", 1)[0] + self.image = f"{base_image}:{tag}" + return self + + def with_bundle(self) -> "ValkeyContainer": + """ + Enable all modules by switching to valkey-bundle image. + + Returns: + self: Container instance for method chaining. + """ + tag = self.image.rsplit(":", 1)[-1] + self.image = f"{_BUNDLE_IMAGE}:{tag}" + return self + + def get_connection_url(self) -> str: + """ + Get connection URL for Valkey. + + Returns: + url: Connection URL in format valkey://[:password@]host:port + """ + host = self.get_host() + port = self.get_exposed_port() + if self.password: + return f"valkey://:{self.password}@{host}:{port}" + return f"valkey://{host}:{port}" + + def get_host(self) -> str: + """ + Get container host. + + Returns: + host: Container host IP. + """ + return self.get_container_host_ip() + + def get_exposed_port(self) -> int: + """ + Get mapped port. + + Returns: + port: Exposed port number. + """ + return int(super().get_exposed_port(self.port)) diff --git a/modules/valkey/tests/test_valkey.py b/modules/valkey/tests/test_valkey.py new file mode 100644 index 000000000..bcdf590ed --- /dev/null +++ b/modules/valkey/tests/test_valkey.py @@ -0,0 +1,84 @@ +import socket + +from testcontainers.valkey import ValkeyContainer + + +def test_docker_run_valkey(): + with ValkeyContainer() as valkey: + host = valkey.get_host() + port = valkey.get_exposed_port() + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.connect((host, port)) + s.sendall(b"*1\r\n$4\r\nPING\r\n") + response = s.recv(1024) + assert b"+PONG" in response + + +def test_docker_run_valkey_with_password(): + with ValkeyContainer().with_password("mypass") as valkey: + host = valkey.get_host() + port = valkey.get_exposed_port() + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.connect((host, port)) + # Authenticate + s.sendall(b"*2\r\n$4\r\nAUTH\r\n$6\r\nmypass\r\n") + auth_response = s.recv(1024) + assert b"+OK" in auth_response + + # Test SET command + s.sendall(b"*3\r\n$3\r\nSET\r\n$5\r\nhello\r\n$5\r\nworld\r\n") + set_response = s.recv(1024) + assert b"+OK" in set_response + + # Test GET command + s.sendall(b"*2\r\n$3\r\nGET\r\n$5\r\nhello\r\n") + get_response = s.recv(1024) + assert b"world" in get_response + + +def test_get_connection_url(): + with ValkeyContainer() as valkey: + url = valkey.get_connection_url() + assert url.startswith("valkey://") + assert str(valkey.get_exposed_port()) in url + + +def test_get_connection_url_with_password(): + with ValkeyContainer().with_password("secret") as valkey: + url = valkey.get_connection_url() + assert url.startswith("valkey://:secret@") + assert str(valkey.get_exposed_port()) in url + + +def test_with_image_tag(): + container = ValkeyContainer().with_image_tag("8.0") + assert container.image == "valkey/valkey:8.0" + + +def test_with_bundle(): + container = ValkeyContainer().with_bundle() + assert container.image == "valkey/valkey-bundle:latest" + + +def test_with_bundle_and_tag(): + container = ValkeyContainer().with_bundle().with_image_tag("9.0") + assert container.image == "valkey/valkey-bundle:9.0" + + +def test_with_tag_and_bundle(): + container = ValkeyContainer().with_image_tag("8.0").with_bundle() + assert container.image == "valkey/valkey-bundle:8.0" + + +def test_bundle_starts(): + with ValkeyContainer().with_bundle() as valkey: + host = valkey.get_host() + port = valkey.get_exposed_port() + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.connect((host, port)) + s.sendall(b"*1\r\n$4\r\nPING\r\n") + response = s.recv(1024) + assert b"+PONG" in response diff --git a/pyproject.toml b/pyproject.toml index 1440bf0d5..e20079fbc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "testcontainers" -version = "4.14.2" # auto-incremented by release-please +version = "4.15.0-rc2" # auto-incremented by release-please description = "Python library for throwaway instances of anything that can run in a Docker container" readme = "README.md" requires-python = ">=3.10" @@ -101,6 +101,8 @@ registry = ["bcrypt>=5"] selenium = ["selenium>=4"] scylla = ["cassandra-driver>=3; python_version < '3.14'"] sftp = ["cryptography"] +temporal = [] +valkey = [] vault = [] weaviate = ["weaviate-client>=4"] chroma = ["chromadb-client>=1"] @@ -216,8 +218,10 @@ packages = [ "modules/registry/testcontainers", "modules/sftp/testcontainers", "modules/selenium/testcontainers", + "modules/temporal/testcontainers", "modules/scylla/testcontainers", "modules/trino/testcontainers", + "modules/valkey/testcontainers", "modules/vault/testcontainers", "modules/weaviate/testcontainers", ] @@ -265,8 +269,10 @@ dev-mode-dirs = [ "modules/registry", "modules/sftp", "modules/selenium", + "modules/temporal", "modules/scylla", "modules/trino", + "modules/valkey", "modules/vault", "modules/weaviate", ] diff --git a/uv.lock b/uv.lock index 22c671f37..9dc734adc 100644 --- a/uv.lock +++ b/uv.lock @@ -30,7 +30,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.3" +version = "3.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -42,110 +42,110 @@ 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" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/36/d6/5aec9313ee6ea9c7cde8b891b69f4ff4001416867104580670a31daeba5b/aiohttp-3.13.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a372fd5afd301b3a89582817fdcdb6c34124787c70dbcc616f259013e7eef7", size = 738950, upload-time = "2026-01-03T17:29:13.002Z" }, - { url = "https://files.pythonhosted.org/packages/68/03/8fa90a7e6d11ff20a18837a8e2b5dd23db01aabc475aa9271c8ad33299f5/aiohttp-3.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:147e422fd1223005c22b4fe080f5d93ced44460f5f9c105406b753612b587821", size = 496099, upload-time = "2026-01-03T17:29:15.268Z" }, - { url = "https://files.pythonhosted.org/packages/d2/23/b81f744d402510a8366b74eb420fc0cc1170d0c43daca12d10814df85f10/aiohttp-3.13.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:859bd3f2156e81dd01432f5849fc73e2243d4a487c4fd26609b1299534ee1845", size = 491072, upload-time = "2026-01-03T17:29:16.922Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e1/56d1d1c0dd334cd203dd97706ce004c1aa24b34a813b0b8daf3383039706/aiohttp-3.13.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dca68018bf48c251ba17c72ed479f4dafe9dbd5a73707ad8d28a38d11f3d42af", size = 1671588, upload-time = "2026-01-03T17:29:18.539Z" }, - { url = "https://files.pythonhosted.org/packages/5f/34/8d7f962604f4bc2b4e39eb1220dac7d4e4cba91fb9ba0474b4ecd67db165/aiohttp-3.13.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fee0c6bc7db1de362252affec009707a17478a00ec69f797d23ca256e36d5940", size = 1640334, upload-time = "2026-01-03T17:29:21.028Z" }, - { url = "https://files.pythonhosted.org/packages/94/1d/fcccf2c668d87337ddeef9881537baee13c58d8f01f12ba8a24215f2b804/aiohttp-3.13.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c048058117fd649334d81b4b526e94bde3ccaddb20463a815ced6ecbb7d11160", size = 1722656, upload-time = "2026-01-03T17:29:22.531Z" }, - { url = "https://files.pythonhosted.org/packages/aa/98/c6f3b081c4c606bc1e5f2ec102e87d6411c73a9ef3616fea6f2d5c98c062/aiohttp-3.13.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:215a685b6fbbfcf71dfe96e3eba7a6f58f10da1dfdf4889c7dd856abe430dca7", size = 1817625, upload-time = "2026-01-03T17:29:24.276Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c0/cfcc3d2e11b477f86e1af2863f3858c8850d751ce8dc39c4058a072c9e54/aiohttp-3.13.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2c184bb1fe2cbd2cefba613e9db29a5ab559323f994b6737e370d3da0ac455", size = 1672604, upload-time = "2026-01-03T17:29:26.099Z" }, - { url = "https://files.pythonhosted.org/packages/1e/77/6b4ffcbcac4c6a5d041343a756f34a6dd26174ae07f977a64fe028dda5b0/aiohttp-3.13.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75ca857eba4e20ce9f546cd59c7007b33906a4cd48f2ff6ccf1ccfc3b646f279", size = 1554370, upload-time = "2026-01-03T17:29:28.121Z" }, - { url = "https://files.pythonhosted.org/packages/f2/f0/e3ddfa93f17d689dbe014ba048f18e0c9f9b456033b70e94349a2e9048be/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81e97251d9298386c2b7dbeb490d3d1badbdc69107fb8c9299dd04eb39bddc0e", size = 1642023, upload-time = "2026-01-03T17:29:30.002Z" }, - { url = "https://files.pythonhosted.org/packages/eb/45/c14019c9ec60a8e243d06d601b33dcc4fd92379424bde3021725859d7f99/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0e2d366af265797506f0283487223146af57815b388623f0357ef7eac9b209d", size = 1649680, upload-time = "2026-01-03T17:29:31.782Z" }, - { url = "https://files.pythonhosted.org/packages/9c/fd/09c9451dae5aa5c5ed756df95ff9ef549d45d4be663bafd1e4954fd836f0/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4e239d501f73d6db1522599e14b9b321a7e3b1de66ce33d53a765d975e9f4808", size = 1692407, upload-time = "2026-01-03T17:29:33.392Z" }, - { url = "https://files.pythonhosted.org/packages/a6/81/938bc2ec33c10efd6637ccb3d22f9f3160d08e8f3aa2587a2c2d5ab578eb/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0db318f7a6f065d84cb1e02662c526294450b314a02bd9e2a8e67f0d8564ce40", size = 1543047, upload-time = "2026-01-03T17:29:34.855Z" }, - { url = "https://files.pythonhosted.org/packages/f7/23/80488ee21c8d567c83045e412e1d9b7077d27171591a4eb7822586e8c06a/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:bfc1cc2fe31a6026a8a88e4ecfb98d7f6b1fec150cfd708adbfd1d2f42257c29", size = 1715264, upload-time = "2026-01-03T17:29:36.389Z" }, - { url = "https://files.pythonhosted.org/packages/e2/83/259a8da6683182768200b368120ab3deff5370bed93880fb9a3a86299f34/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af71fff7bac6bb7508956696dce8f6eec2bbb045eceb40343944b1ae62b5ef11", size = 1657275, upload-time = "2026-01-03T17:29:38.162Z" }, - { url = "https://files.pythonhosted.org/packages/3f/4f/2c41f800a0b560785c10fb316216ac058c105f9be50bdc6a285de88db625/aiohttp-3.13.3-cp310-cp310-win32.whl", hash = "sha256:37da61e244d1749798c151421602884db5270faf479cf0ef03af0ff68954c9dd", size = 434053, upload-time = "2026-01-03T17:29:40.074Z" }, - { url = "https://files.pythonhosted.org/packages/80/df/29cd63c7ecfdb65ccc12f7d808cac4fa2a19544660c06c61a4a48462de0c/aiohttp-3.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:7e63f210bc1b57ef699035f2b4b6d9ce096b5914414a49b0997c839b2bd2223c", size = 456687, upload-time = "2026-01-03T17:29:41.819Z" }, - { url = "https://files.pythonhosted.org/packages/f1/4c/a164164834f03924d9a29dc3acd9e7ee58f95857e0b467f6d04298594ebb/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051, upload-time = "2026-01-03T17:29:43.287Z" }, - { url = "https://files.pythonhosted.org/packages/82/71/d5c31390d18d4f58115037c432b7e0348c60f6f53b727cad33172144a112/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64", size = 499234, upload-time = "2026-01-03T17:29:44.822Z" }, - { url = "https://files.pythonhosted.org/packages/0e/c9/741f8ac91e14b1d2e7100690425a5b2b919a87a5075406582991fb7de920/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea", size = 494979, upload-time = "2026-01-03T17:29:46.405Z" }, - { url = "https://files.pythonhosted.org/packages/75/b5/31d4d2e802dfd59f74ed47eba48869c1c21552c586d5e81a9d0d5c2ad640/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a", size = 1748297, upload-time = "2026-01-03T17:29:48.083Z" }, - { url = "https://files.pythonhosted.org/packages/1a/3e/eefad0ad42959f226bb79664826883f2687d602a9ae2941a18e0484a74d3/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540", size = 1707172, upload-time = "2026-01-03T17:29:49.648Z" }, - { url = "https://files.pythonhosted.org/packages/c5/3a/54a64299fac2891c346cdcf2aa6803f994a2e4beeaf2e5a09dcc54acc842/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b", size = 1805405, upload-time = "2026-01-03T17:29:51.244Z" }, - { url = "https://files.pythonhosted.org/packages/6c/70/ddc1b7169cf64075e864f64595a14b147a895a868394a48f6a8031979038/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3", size = 1899449, upload-time = "2026-01-03T17:29:53.938Z" }, - { url = "https://files.pythonhosted.org/packages/a1/7e/6815aab7d3a56610891c76ef79095677b8b5be6646aaf00f69b221765021/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1", size = 1748444, upload-time = "2026-01-03T17:29:55.484Z" }, - { url = "https://files.pythonhosted.org/packages/6b/f2/073b145c4100da5511f457dc0f7558e99b2987cf72600d42b559db856fbc/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3", size = 1606038, upload-time = "2026-01-03T17:29:57.179Z" }, - { url = "https://files.pythonhosted.org/packages/0a/c1/778d011920cae03ae01424ec202c513dc69243cf2db303965615b81deeea/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440", size = 1724156, upload-time = "2026-01-03T17:29:58.914Z" }, - { url = "https://files.pythonhosted.org/packages/0e/cb/3419eabf4ec1e9ec6f242c32b689248365a1cf621891f6f0386632525494/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7", size = 1722340, upload-time = "2026-01-03T17:30:01.962Z" }, - { url = "https://files.pythonhosted.org/packages/7a/e5/76cf77bdbc435bf233c1f114edad39ed4177ccbfab7c329482b179cff4f4/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c", size = 1783041, upload-time = "2026-01-03T17:30:03.609Z" }, - { url = "https://files.pythonhosted.org/packages/9d/d4/dd1ca234c794fd29c057ce8c0566b8ef7fd6a51069de5f06fa84b9a1971c/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51", size = 1596024, upload-time = "2026-01-03T17:30:05.132Z" }, - { url = "https://files.pythonhosted.org/packages/55/58/4345b5f26661a6180afa686c473620c30a66afdf120ed3dd545bbc809e85/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4", size = 1804590, upload-time = "2026-01-03T17:30:07.135Z" }, - { url = "https://files.pythonhosted.org/packages/7b/06/05950619af6c2df7e0a431d889ba2813c9f0129cec76f663e547a5ad56f2/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29", size = 1740355, upload-time = "2026-01-03T17:30:09.083Z" }, - { url = "https://files.pythonhosted.org/packages/3e/80/958f16de79ba0422d7c1e284b2abd0c84bc03394fbe631d0a39ffa10e1eb/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239", size = 433701, upload-time = "2026-01-03T17:30:10.869Z" }, - { url = "https://files.pythonhosted.org/packages/dc/f2/27cdf04c9851712d6c1b99df6821a6623c3c9e55956d4b1e318c337b5a48/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f", size = 457678, upload-time = "2026-01-03T17:30:12.719Z" }, - { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" }, - { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" }, - { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" }, - { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" }, - { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" }, - { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" }, - { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" }, - { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" }, - { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" }, - { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" }, - { 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/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" }, - { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" }, - { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" }, - { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" }, - { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" }, - { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" }, - { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" }, - { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" }, - { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" }, - { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" }, - { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" }, - { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" }, - { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" }, - { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" }, - { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" }, - { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" }, - { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" }, - { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" }, - { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" }, - { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" }, - { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" }, - { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" }, - { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" }, - { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" }, - { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" }, - { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" }, - { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" }, - { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" }, - { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/45/4a/064321452809dae953c1ed6e017504e72551a26b6f5708a5a80e4bf556ff/aiohttp-3.13.4.tar.gz", hash = "sha256:d97a6d09c66087890c2ab5d49069e1e570583f7ac0314ecf98294c1b6aaebd38", size = 7859748, upload-time = "2026-03-28T17:19:40.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/05/6817e0390eb47b0867cf8efdb535298191662192281bc3ca62a0cb7973eb/aiohttp-3.13.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6290fe12fe8cefa6ea3c1c5b969d32c010dfe191d4392ff9b599a3f473cbe722", size = 753094, upload-time = "2026-03-28T17:14:59.928Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c1/e5b7f25f6dd1ab57da92aa9d226b2c8b56f223dd20475d3ddfddaba86ab8/aiohttp-3.13.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7520d92c0e8fbbe63f36f20a5762db349ff574ad38ad7bc7732558a650439845", size = 505213, upload-time = "2026-03-28T17:15:01.989Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e5/8f42033c7ce98b54dfd3791f03e60231cfe4a2db4471b5fc188df2b8a6ad/aiohttp-3.13.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d2710ae1e1b81d0f187883b6e9d66cecf8794b50e91aa1e73fc78bfb5503b5d9", size = 498580, upload-time = "2026-03-28T17:15:03.879Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/bbc989f5362066b81930da1a66084a859a971d03faab799dc59a3ce3a220/aiohttp-3.13.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:717d17347567ded1e273aa09918650dfd6fd06f461549204570c7973537d4123", size = 1692718, upload-time = "2026-03-28T17:15:05.541Z" }, + { url = "https://files.pythonhosted.org/packages/1c/72/3775116969931f151be116689d2ae6ddafff2ec2887d8f9b4e7043f32e74/aiohttp-3.13.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:383880f7b8de5ac208fa829c7038d08e66377283b2de9e791b71e06e803153c2", size = 1660714, upload-time = "2026-03-28T17:15:08.23Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e8/d2f1a2da2743e32fe348ebf8a4c59caad14a92f5f18af616fd33381275e1/aiohttp-3.13.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1867087e2c1963db1216aedf001efe3b129835ed2b05d97d058176a6d08b5726", size = 1744152, upload-time = "2026-03-28T17:15:10.828Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a6/575886f417ac3c08e462f2ca237cc49f436bd992ca3f7ff95b7dd9c44205/aiohttp-3.13.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6234bf416a38d687c3ab7f79934d7fb2a42117a5b9813aca07de0a5398489023", size = 1836278, upload-time = "2026-03-28T17:15:12.537Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4c/0051d4550fb9e8b5ca4e0fe1ccd58652340915180c5164999e6741bf2083/aiohttp-3.13.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3cdd3393130bf6588962441ffd5bde1d3ea2d63a64afa7119b3f3ba349cebbe7", size = 1687953, upload-time = "2026-03-28T17:15:14.248Z" }, + { url = "https://files.pythonhosted.org/packages/c9/54/841e87b8c51c2adc01a3ceb9919dc45c7899fe4c21deb70aada734ea5a38/aiohttp-3.13.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d0dbc6c76befa76865373d6aa303e480bb8c3486e7763530f7f6e527b471118", size = 1572484, upload-time = "2026-03-28T17:15:15.911Z" }, + { url = "https://files.pythonhosted.org/packages/da/f1/21cbf5f7fa1e267af6301f886cab9b314f085e4d0097668d189d165cd7da/aiohttp-3.13.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:10fb7b53262cf4144a083c9db0d2b4d22823d6708270a9970c4627b248c6064c", size = 1662851, upload-time = "2026-03-28T17:15:17.822Z" }, + { url = "https://files.pythonhosted.org/packages/40/15/bcad6b68d7bef27ae7443288215767263c7753ede164267cf6cf63c94a87/aiohttp-3.13.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:eb10ce8c03850e77f4d9518961c227be569e12f71525a7e90d17bca04299921d", size = 1671984, upload-time = "2026-03-28T17:15:19.561Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/ab316931afc7a73c7f493bb1b30fbd61e28ec2d3ea50353336e76293e8ec/aiohttp-3.13.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7c65738ac5ae32b8feef699a4ed0dc91a0c8618b347781b7461458bbcaaac7eb", size = 1713880, upload-time = "2026-03-28T17:15:21.589Z" }, + { url = "https://files.pythonhosted.org/packages/1c/45/314e8e64c7f328174964b6db511dd5e9e60c9121ab5457bc2c908b7d03a4/aiohttp-3.13.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:6b335919ffbaf98df8ff3c74f7a6decb8775882632952fd1810a017e38f15aee", size = 1560315, upload-time = "2026-03-28T17:15:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/18/e7/93d5fa06fe00219a81466577dacae9e3732f3b4f767b12b2e2cc8c35c970/aiohttp-3.13.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:ec75fc18cb9f4aca51c2cbace20cf6716e36850f44189644d2d69a875d5e0532", size = 1735115, upload-time = "2026-03-28T17:15:25.77Z" }, + { url = "https://files.pythonhosted.org/packages/19/9f/f64b95392ddd4e204fd9ab7cd33dd18d14ac9e4b86866f1f6a69b7cda83d/aiohttp-3.13.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:463fa18a95c5a635d2b8c09babe240f9d7dbf2a2010a6c0b35d8c4dff2a0e819", size = 1673916, upload-time = "2026-03-28T17:15:27.526Z" }, + { url = "https://files.pythonhosted.org/packages/52/c1/bb33be79fd285c69f32e5b074b299cae8847f748950149c3965c1b3b3adf/aiohttp-3.13.4-cp310-cp310-win32.whl", hash = "sha256:13168f5645d9045522c6cef818f54295376257ed8d02513a37c2ef3046fc7a97", size = 440277, upload-time = "2026-03-28T17:15:29.173Z" }, + { url = "https://files.pythonhosted.org/packages/23/f9/7cf1688da4dd0885f914ee40bc8e1dce776df98fe6518766de975a570538/aiohttp-3.13.4-cp310-cp310-win_amd64.whl", hash = "sha256:a7058af1f53209fdf07745579ced525d38d481650a989b7aa4a3b484b901cdab", size = 463015, upload-time = "2026-03-28T17:15:30.802Z" }, + { url = "https://files.pythonhosted.org/packages/d4/7e/cb94129302d78c46662b47f9897d642fd0b33bdfef4b73b20c6ced35aa4c/aiohttp-3.13.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8ea0c64d1bcbf201b285c2246c51a0c035ba3bbd306640007bc5844a3b4658c1", size = 760027, upload-time = "2026-03-28T17:15:33.022Z" }, + { url = "https://files.pythonhosted.org/packages/5e/cd/2db3c9397c3bd24216b203dd739945b04f8b87bb036c640da7ddb63c75ef/aiohttp-3.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6f742e1fa45c0ed522b00ede565e18f97e4cf8d1883a712ac42d0339dfb0cce7", size = 508325, upload-time = "2026-03-28T17:15:34.714Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/d28b2722ec13107f2e37a86b8a169897308bab6a3b9e071ecead9d67bd9b/aiohttp-3.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dcfb50ee25b3b7a1222a9123be1f9f89e56e67636b561441f0b304e25aaef8f", size = 502402, upload-time = "2026-03-28T17:15:36.409Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d6/acd47b5f17c4430e555590990a4746efbcb2079909bb865516892bf85f37/aiohttp-3.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3262386c4ff370849863ea93b9ea60fd59c6cf56bf8f93beac625cf4d677c04d", size = 1771224, upload-time = "2026-03-28T17:15:38.223Z" }, + { url = "https://files.pythonhosted.org/packages/98/af/af6e20113ba6a48fd1cd9e5832c4851e7613ef50c7619acdaee6ec5f1aff/aiohttp-3.13.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:473bb5aa4218dd254e9ae4834f20e31f5a0083064ac0136a01a62ddbae2eaa42", size = 1731530, upload-time = "2026-03-28T17:15:39.988Z" }, + { url = "https://files.pythonhosted.org/packages/81/16/78a2f5d9c124ad05d5ce59a9af94214b6466c3491a25fb70760e98e9f762/aiohttp-3.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56423766399b4c77b965f6aaab6c9546617b8994a956821cc507d00b91d978c", size = 1827925, upload-time = "2026-03-28T17:15:41.944Z" }, + { url = "https://files.pythonhosted.org/packages/2a/1f/79acf0974ced805e0e70027389fccbb7d728e6f30fcac725fb1071e63075/aiohttp-3.13.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8af249343fafd5ad90366a16d230fc265cf1149f26075dc9fe93cfd7c7173942", size = 1923579, upload-time = "2026-03-28T17:15:44.071Z" }, + { url = "https://files.pythonhosted.org/packages/af/53/29f9e2054ea6900413f3b4c3eb9d8331f60678ec855f13ba8714c47fd48d/aiohttp-3.13.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bc0a5cf4f10ef5a2c94fdde488734b582a3a7a000b131263e27c9295bd682d9", size = 1767655, upload-time = "2026-03-28T17:15:45.911Z" }, + { url = "https://files.pythonhosted.org/packages/f3/57/462fe1d3da08109ba4aa8590e7aed57c059af2a7e80ec21f4bac5cfe1094/aiohttp-3.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5c7ff1028e3c9fc5123a865ce17df1cb6424d180c503b8517afbe89aa566e6be", size = 1630439, upload-time = "2026-03-28T17:15:48.11Z" }, + { url = "https://files.pythonhosted.org/packages/d7/4b/4813344aacdb8127263e3eec343d24e973421143826364fa9fc847f6283f/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ba5cf98b5dcb9bddd857da6713a503fa6d341043258ca823f0f5ab7ab4a94ee8", size = 1745557, upload-time = "2026-03-28T17:15:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/d4/01/1ef1adae1454341ec50a789f03cfafe4c4ac9c003f6a64515ecd32fe4210/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d85965d3ba21ee4999e83e992fecb86c4614d6920e40705501c0a1f80a583c12", size = 1741796, upload-time = "2026-03-28T17:15:52.351Z" }, + { url = "https://files.pythonhosted.org/packages/22/04/8cdd99af988d2aa6922714d957d21383c559835cbd43fbf5a47ddf2e0f05/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:49f0b18a9b05d79f6f37ddd567695943fcefb834ef480f17a4211987302b2dc7", size = 1805312, upload-time = "2026-03-28T17:15:54.407Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/b48d5577338d4b25bbdbae35c75dbfd0493cb8886dc586fbfb2e90862239/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7f78cb080c86fbf765920e5f1ef35af3f24ec4314d6675d0a21eaf41f6f2679c", size = 1621751, upload-time = "2026-03-28T17:15:56.564Z" }, + { url = "https://files.pythonhosted.org/packages/bc/89/4eecad8c1858e6d0893c05929e22343e0ebe3aec29a8a399c65c3cc38311/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:67a3ec705534a614b68bbf1c70efa777a21c3da3895d1c44510a41f5a7ae0453", size = 1826073, upload-time = "2026-03-28T17:15:58.489Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5c/9dc8293ed31b46c39c9c513ac7ca152b3c3d38e0ea111a530ad12001b827/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d6630ec917e85c5356b2295744c8a97d40f007f96a1c76bf1928dc2e27465393", size = 1760083, upload-time = "2026-03-28T17:16:00.677Z" }, + { url = "https://files.pythonhosted.org/packages/1e/19/8bbf6a4994205d96831f97b7d21a0feed120136e6267b5b22d229c6dc4dc/aiohttp-3.13.4-cp311-cp311-win32.whl", hash = "sha256:54049021bc626f53a5394c29e8c444f726ee5a14b6e89e0ad118315b1f90f5e3", size = 439690, upload-time = "2026-03-28T17:16:02.902Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f5/ac409ecd1007528d15c3e8c3a57d34f334c70d76cfb7128a28cffdebd4c1/aiohttp-3.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:c033f2bc964156030772d31cbf7e5defea181238ce1f87b9455b786de7d30145", size = 463824, upload-time = "2026-03-28T17:16:05.058Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bd/ede278648914cabbabfdf95e436679b5d4156e417896a9b9f4587169e376/aiohttp-3.13.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ee62d4471ce86b108b19c3364db4b91180d13fe3510144872d6bad5401957360", size = 752158, upload-time = "2026-03-28T17:16:06.901Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/581c053253c07b480b03785196ca5335e3c606a37dc73e95f6527f1591fe/aiohttp-3.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c0fd8f41b54b58636402eb493afd512c23580456f022c1ba2db0f810c959ed0d", size = 501037, upload-time = "2026-03-28T17:16:08.82Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/a5ede193c08f13cc42c0a5b50d1e246ecee9115e4cf6e900d8dbd8fd6acb/aiohttp-3.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4baa48ce49efd82d6b1a0be12d6a36b35e5594d1dd42f8bfba96ea9f8678b88c", size = 501556, upload-time = "2026-03-28T17:16:10.63Z" }, + { url = "https://files.pythonhosted.org/packages/d6/10/88ff67cd48a6ec36335b63a640abe86135791544863e0cfe1f065d6cef7a/aiohttp-3.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d738ebab9f71ee652d9dbd0211057690022201b11197f9a7324fd4dba128aa97", size = 1757314, upload-time = "2026-03-28T17:16:12.498Z" }, + { url = "https://files.pythonhosted.org/packages/8b/15/fdb90a5cf5a1f52845c276e76298c75fbbcc0ac2b4a86551906d54529965/aiohttp-3.13.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0ce692c3468fa831af7dceed52edf51ac348cebfc8d3feb935927b63bd3e8576", size = 1731819, upload-time = "2026-03-28T17:16:14.558Z" }, + { url = "https://files.pythonhosted.org/packages/ec/df/28146785a007f7820416be05d4f28cc207493efd1e8c6c1068e9bdc29198/aiohttp-3.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e08abcfe752a454d2cb89ff0c08f2d1ecd057ae3e8cc6d84638de853530ebab", size = 1793279, upload-time = "2026-03-28T17:16:16.594Z" }, + { url = "https://files.pythonhosted.org/packages/10/47/689c743abf62ea7a77774d5722f220e2c912a77d65d368b884d9779ef41b/aiohttp-3.13.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5977f701b3fff36367a11087f30ea73c212e686d41cd363c50c022d48b011d8d", size = 1891082, upload-time = "2026-03-28T17:16:18.71Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/f7f4f318c7e58c23b761c9b13b9a3c9b394e0f9d5d76fbc6622fa98509f6/aiohttp-3.13.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54203e10405c06f8b6020bd1e076ae0fe6c194adcee12a5a78af3ffa3c57025e", size = 1773938, upload-time = "2026-03-28T17:16:21.125Z" }, + { url = "https://files.pythonhosted.org/packages/aa/06/f207cb3121852c989586a6fc16ff854c4fcc8651b86c5d3bd1fc83057650/aiohttp-3.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:358a6af0145bc4dda037f13167bef3cce54b132087acc4c295c739d05d16b1c3", size = 1579548, upload-time = "2026-03-28T17:16:23.588Z" }, + { url = "https://files.pythonhosted.org/packages/6c/58/e1289661a32161e24c1fe479711d783067210d266842523752869cc1d9c2/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:898ea1850656d7d61832ef06aa9846ab3ddb1621b74f46de78fbc5e1a586ba83", size = 1714669, upload-time = "2026-03-28T17:16:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/96/0a/3e86d039438a74a86e6a948a9119b22540bae037d6ba317a042ae3c22711/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7bc30cceb710cf6a44e9617e43eebb6e3e43ad855a34da7b4b6a73537d8a6763", size = 1754175, upload-time = "2026-03-28T17:16:28.18Z" }, + { url = "https://files.pythonhosted.org/packages/f4/30/e717fc5df83133ba467a560b6d8ef20197037b4bb5d7075b90037de1018e/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4a31c0c587a8a038f19a4c7e60654a6c899c9de9174593a13e7cc6e15ff271f9", size = 1762049, upload-time = "2026-03-28T17:16:30.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/28/8f7a2d4492e336e40005151bdd94baf344880a4707573378579f833a64c1/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2062f675f3fe6e06d6113eb74a157fb9df58953ffed0cdb4182554b116545758", size = 1570861, upload-time = "2026-03-28T17:16:32.953Z" }, + { url = "https://files.pythonhosted.org/packages/78/45/12e1a3d0645968b1c38de4b23fdf270b8637735ea057d4f84482ff918ad9/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d1ba8afb847ff80626d5e408c1fdc99f942acc877d0702fe137015903a220a9", size = 1790003, upload-time = "2026-03-28T17:16:35.468Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/60374e18d590de16dcb39d6ff62f39c096c1b958e6f37727b5870026ea30/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b08149419994cdd4d5eecf7fd4bc5986b5a9380285bcd01ab4c0d6bfca47b79d", size = 1737289, upload-time = "2026-03-28T17:16:38.187Z" }, + { url = "https://files.pythonhosted.org/packages/02/bf/535e58d886cfbc40a8b0013c974afad24ef7632d645bca0b678b70033a60/aiohttp-3.13.4-cp312-cp312-win32.whl", hash = "sha256:fc432f6a2c4f720180959bc19aa37259651c1a4ed8af8afc84dd41c60f15f791", size = 434185, upload-time = "2026-03-28T17:16:40.735Z" }, + { url = "https://files.pythonhosted.org/packages/1e/1a/d92e3325134ebfff6f4069f270d3aac770d63320bd1fcd0eca023e74d9a8/aiohttp-3.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:6148c9ae97a3e8bff9a1fc9c757fa164116f86c100468339730e717590a3fb77", size = 461285, upload-time = "2026-03-28T17:16:42.713Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ac/892f4162df9b115b4758d615f32ec63d00f3084c705ff5526630887b9b42/aiohttp-3.13.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:63dd5e5b1e43b8fb1e91b79b7ceba1feba588b317d1edff385084fcc7a0a4538", size = 745744, upload-time = "2026-03-28T17:16:44.67Z" }, + { url = "https://files.pythonhosted.org/packages/97/a9/c5b87e4443a2f0ea88cb3000c93a8fdad1ee63bffc9ded8d8c8e0d66efc6/aiohttp-3.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:746ac3cc00b5baea424dacddea3ec2c2702f9590de27d837aa67004db1eebc6e", size = 498178, upload-time = "2026-03-28T17:16:46.766Z" }, + { url = "https://files.pythonhosted.org/packages/94/42/07e1b543a61250783650df13da8ddcdc0d0a5538b2bd15cef6e042aefc61/aiohttp-3.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bda8f16ea99d6a6705e5946732e48487a448be874e54a4f73d514660ff7c05d3", size = 498331, upload-time = "2026-03-28T17:16:48.9Z" }, + { url = "https://files.pythonhosted.org/packages/20/d6/492f46bf0328534124772d0cf58570acae5b286ea25006900650f69dae0e/aiohttp-3.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b061e7b5f840391e3f64d0ddf672973e45c4cfff7a0feea425ea24e51530fc2", size = 1744414, upload-time = "2026-03-28T17:16:50.968Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4d/e02627b2683f68051246215d2d62b2d2f249ff7a285e7a858dc47d6b6a14/aiohttp-3.13.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b252e8d5cd66184b570d0d010de742736e8a4fab22c58299772b0c5a466d4b21", size = 1719226, upload-time = "2026-03-28T17:16:53.173Z" }, + { url = "https://files.pythonhosted.org/packages/7b/6c/5d0a3394dd2b9f9aeba6e1b6065d0439e4b75d41f1fb09a3ec010b43552b/aiohttp-3.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20af8aad61d1803ff11152a26146d8d81c266aa8c5aa9b4504432abb965c36a0", size = 1782110, upload-time = "2026-03-28T17:16:55.362Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2d/c20791e3437700a7441a7edfb59731150322424f5aadf635602d1d326101/aiohttp-3.13.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:13a5cc924b59859ad2adb1478e31f410a7ed46e92a2a619d6d1dd1a63c1a855e", size = 1884809, upload-time = "2026-03-28T17:16:57.734Z" }, + { url = "https://files.pythonhosted.org/packages/c8/94/d99dbfbd1924a87ef643833932eb2a3d9e5eee87656efea7d78058539eff/aiohttp-3.13.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:534913dfb0a644d537aebb4123e7d466d94e3be5549205e6a31f72368980a81a", size = 1764938, upload-time = "2026-03-28T17:17:00.221Z" }, + { url = "https://files.pythonhosted.org/packages/49/61/3ce326a1538781deb89f6cf5e094e2029cd308ed1e21b2ba2278b08426f6/aiohttp-3.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:320e40192a2dcc1cf4b5576936e9652981ab596bf81eb309535db7e2f5b5672f", size = 1570697, upload-time = "2026-03-28T17:17:02.985Z" }, + { url = "https://files.pythonhosted.org/packages/b6/77/4ab5a546857bb3028fbaf34d6eea180267bdab022ee8b1168b1fcde4bfdd/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9e587fcfce2bcf06526a43cb705bdee21ac089096f2e271d75de9c339db3100c", size = 1702258, upload-time = "2026-03-28T17:17:05.28Z" }, + { url = "https://files.pythonhosted.org/packages/79/63/d8f29021e39bc5af8e5d5e9da1b07976fb9846487a784e11e4f4eeda4666/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9eb9c2eea7278206b5c6c1441fdd9dc420c278ead3f3b2cc87f9b693698cc500", size = 1740287, upload-time = "2026-03-28T17:17:07.712Z" }, + { url = "https://files.pythonhosted.org/packages/55/3a/cbc6b3b124859a11bc8055d3682c26999b393531ef926754a3445b99dfef/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:29be00c51972b04bf9d5c8f2d7f7314f48f96070ca40a873a53056e652e805f7", size = 1753011, upload-time = "2026-03-28T17:17:10.053Z" }, + { url = "https://files.pythonhosted.org/packages/e0/30/836278675205d58c1368b21520eab9572457cf19afd23759216c04483048/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90c06228a6c3a7c9f776fe4fc0b7ff647fffd3bed93779a6913c804ae00c1073", size = 1566359, upload-time = "2026-03-28T17:17:12.433Z" }, + { url = "https://files.pythonhosted.org/packages/50/b4/8032cc9b82d17e4277704ba30509eaccb39329dc18d6a35f05e424439e32/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a533ec132f05fd9a1d959e7f34184cd7d5e8511584848dab85faefbaac573069", size = 1785537, upload-time = "2026-03-28T17:17:14.721Z" }, + { url = "https://files.pythonhosted.org/packages/17/7d/5873e98230bde59f493bf1f7c3e327486a4b5653fa401144704df5d00211/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1c946f10f413836f82ea4cfb90200d2a59578c549f00857e03111cf45ad01ca5", size = 1740752, upload-time = "2026-03-28T17:17:17.387Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f2/13e46e0df051494d7d3c68b7f72d071f48c384c12716fc294f75d5b1a064/aiohttp-3.13.4-cp313-cp313-win32.whl", hash = "sha256:48708e2706106da6967eff5908c78ca3943f005ed6bcb75da2a7e4da94ef8c70", size = 433187, upload-time = "2026-03-28T17:17:19.523Z" }, + { url = "https://files.pythonhosted.org/packages/ea/c0/649856ee655a843c8f8664592cfccb73ac80ede6a8c8db33a25d810c12db/aiohttp-3.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:74a2eb058da44fa3a877a49e2095b591d4913308bb424c418b77beb160c55ce3", size = 459778, upload-time = "2026-03-28T17:17:21.964Z" }, + { url = "https://files.pythonhosted.org/packages/6d/29/6657cc37ae04cacc2dbf53fb730a06b6091cc4cbe745028e047c53e6d840/aiohttp-3.13.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:e0a2c961fc92abeff61d6444f2ce6ad35bb982db9fc8ff8a47455beacf454a57", size = 749363, upload-time = "2026-03-28T17:17:24.044Z" }, + { url = "https://files.pythonhosted.org/packages/90/7f/30ccdf67ca3d24b610067dc63d64dcb91e5d88e27667811640644aa4a85d/aiohttp-3.13.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:153274535985a0ff2bff1fb6c104ed547cec898a09213d21b0f791a44b14d933", size = 499317, upload-time = "2026-03-28T17:17:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/93/13/e372dd4e68ad04ee25dafb050c7f98b0d91ea643f7352757e87231102555/aiohttp-3.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:351f3171e2458da3d731ce83f9e6b9619e325c45cbd534c7759750cabf453ad7", size = 500477, upload-time = "2026-03-28T17:17:28.279Z" }, + { url = "https://files.pythonhosted.org/packages/e5/fe/ee6298e8e586096fb6f5eddd31393d8544f33ae0792c71ecbb4c2bef98ac/aiohttp-3.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f989ac8bc5595ff761a5ccd32bdb0768a117f36dd1504b1c2c074ed5d3f4df9c", size = 1737227, upload-time = "2026-03-28T17:17:30.587Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b9/a7a0463a09e1a3fe35100f74324f23644bfc3383ac5fd5effe0722a5f0b7/aiohttp-3.13.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d36fc1709110ec1e87a229b201dd3ddc32aa01e98e7868083a794609b081c349", size = 1694036, upload-time = "2026-03-28T17:17:33.29Z" }, + { url = "https://files.pythonhosted.org/packages/57/7c/8972ae3fb7be00a91aee6b644b2a6a909aedb2c425269a3bfd90115e6f8f/aiohttp-3.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42adaeea83cbdf069ab94f5103ce0787c21fb1a0153270da76b59d5578302329", size = 1786814, upload-time = "2026-03-28T17:17:36.035Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/c81e97e85c774decbaf0d577de7d848934e8166a3a14ad9f8aa5be329d28/aiohttp-3.13.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:92deb95469928cc41fd4b42a95d8012fa6df93f6b1c0a83af0ffbc4a5e218cde", size = 1866676, upload-time = "2026-03-28T17:17:38.441Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5f/5b46fe8694a639ddea2cd035bf5729e4677ea882cb251396637e2ef1590d/aiohttp-3.13.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0c7c07c4257ef3a1df355f840bc62d133bcdef5c1c5ba75add3c08553e2eed", size = 1740842, upload-time = "2026-03-28T17:17:40.783Z" }, + { url = "https://files.pythonhosted.org/packages/20/a2/0d4b03d011cca6b6b0acba8433193c1e484efa8d705ea58295590fe24203/aiohttp-3.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f062c45de8a1098cb137a1898819796a2491aec4e637a06b03f149315dff4d8f", size = 1566508, upload-time = "2026-03-28T17:17:43.235Z" }, + { url = "https://files.pythonhosted.org/packages/98/17/e689fd500da52488ec5f889effd6404dece6a59de301e380f3c64f167beb/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:76093107c531517001114f0ebdb4f46858ce818590363e3e99a4a2280334454a", size = 1700569, upload-time = "2026-03-28T17:17:46.165Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0d/66402894dbcf470ef7db99449e436105ea862c24f7ea4c95c683e635af35/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6f6ec32162d293b82f8b63a16edc80769662fbd5ae6fbd4936d3206a2c2cc63b", size = 1707407, upload-time = "2026-03-28T17:17:48.825Z" }, + { url = "https://files.pythonhosted.org/packages/2f/eb/af0ab1a3650092cbd8e14ef29e4ab0209e1460e1c299996c3f8288b3f1ff/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5903e2db3d202a00ad9f0ec35a122c005e85d90c9836ab4cda628f01edf425e2", size = 1752214, upload-time = "2026-03-28T17:17:51.206Z" }, + { url = "https://files.pythonhosted.org/packages/5a/bf/72326f8a98e4c666f292f03c385545963cc65e358835d2a7375037a97b57/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2d5bea57be7aca98dbbac8da046d99b5557c5cf4e28538c4c786313078aca09e", size = 1562162, upload-time = "2026-03-28T17:17:53.634Z" }, + { url = "https://files.pythonhosted.org/packages/67/9f/13b72435f99151dd9a5469c96b3b5f86aa29b7e785ca7f35cf5e538f74c0/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:bcf0c9902085976edc0232b75006ef38f89686901249ce14226b6877f88464fb", size = 1768904, upload-time = "2026-03-28T17:17:55.991Z" }, + { url = "https://files.pythonhosted.org/packages/18/bc/28d4970e7d5452ac7776cdb5431a1164a0d9cf8bd2fffd67b4fb463aa56d/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3295f98bfeed2e867cab588f2a146a9db37a85e3ae9062abf46ba062bd29165", size = 1723378, upload-time = "2026-03-28T17:17:58.348Z" }, + { url = "https://files.pythonhosted.org/packages/53/74/b32458ca1a7f34d65bdee7aef2036adbe0438123d3d53e2b083c453c24dd/aiohttp-3.13.4-cp314-cp314-win32.whl", hash = "sha256:a598a5c5767e1369d8f5b08695cab1d8160040f796c4416af76fd773d229b3c9", size = 438711, upload-time = "2026-03-28T17:18:00.728Z" }, + { url = "https://files.pythonhosted.org/packages/40/b2/54b487316c2df3e03a8f3435e9636f8a81a42a69d942164830d193beb56a/aiohttp-3.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:c555db4bc7a264bead5a7d63d92d41a1122fcd39cc62a4db815f45ad46f9c2c8", size = 464977, upload-time = "2026-03-28T17:18:03.367Z" }, + { url = "https://files.pythonhosted.org/packages/47/fb/e41b63c6ce71b07a59243bb8f3b457ee0c3402a619acb9d2c0d21ef0e647/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45abbbf09a129825d13c18c7d3182fecd46d9da3cfc383756145394013604ac1", size = 781549, upload-time = "2026-03-28T17:18:05.779Z" }, + { url = "https://files.pythonhosted.org/packages/97/53/532b8d28df1e17e44c4d9a9368b78dcb6bf0b51037522136eced13afa9e8/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:74c80b2bc2c2adb7b3d1941b2b60701ee2af8296fc8aad8b8bc48bc25767266c", size = 514383, upload-time = "2026-03-28T17:18:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1f/62e5d400603e8468cd635812d99cb81cfdc08127a3dc474c647615f31339/aiohttp-3.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c97989ae40a9746650fa196894f317dafc12227c808c774929dda0ff873a5954", size = 518304, upload-time = "2026-03-28T17:18:10.642Z" }, + { url = "https://files.pythonhosted.org/packages/90/57/2326b37b10896447e3c6e0cbef4fe2486d30913639a5cfd1332b5d870f82/aiohttp-3.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dae86be9811493f9990ef44fff1685f5c1a3192e9061a71a109d527944eed551", size = 1893433, upload-time = "2026-03-28T17:18:13.121Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b4/a24d82112c304afdb650167ef2fe190957d81cbddac7460bedd245f765aa/aiohttp-3.13.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1db491abe852ca2fa6cc48a3341985b0174b3741838e1341b82ac82c8bd9e871", size = 1755901, upload-time = "2026-03-28T17:18:16.21Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0883ef9d878d7846287f036c162a951968f22aabeef3ac97b0bea6f76d5d/aiohttp-3.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e5d701c0aad02a7dce72eef6b93226cf3734330f1a31d69ebbf69f33b86666e", size = 1876093, upload-time = "2026-03-28T17:18:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/ad/52/9204bb59c014869b71971addad6778f005daa72a96eed652c496789d7468/aiohttp-3.13.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8ac32a189081ae0a10ba18993f10f338ec94341f0d5df8fff348043962f3c6f8", size = 1970815, upload-time = "2026-03-28T17:18:21.858Z" }, + { url = "https://files.pythonhosted.org/packages/d6/b5/e4eb20275a866dde0f570f411b36c6b48f7b53edfe4f4071aa1b0728098a/aiohttp-3.13.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98e968cdaba43e45c73c3f306fca418c8009a957733bac85937c9f9cf3f4de27", size = 1816223, upload-time = "2026-03-28T17:18:24.729Z" }, + { url = "https://files.pythonhosted.org/packages/d8/23/e98075c5bb146aa61a1239ee1ac7714c85e814838d6cebbe37d3fe19214a/aiohttp-3.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca114790c9144c335d538852612d3e43ea0f075288f4849cf4b05d6cd2238ce7", size = 1649145, upload-time = "2026-03-28T17:18:27.269Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c1/7bad8be33bb06c2bb224b6468874346026092762cbec388c3bdb65a368ee/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ea2e071661ba9cfe11eabbc81ac5376eaeb3061f6e72ec4cc86d7cdd1ffbdbbb", size = 1816562, upload-time = "2026-03-28T17:18:29.847Z" }, + { url = "https://files.pythonhosted.org/packages/5c/10/c00323348695e9a5e316825969c88463dcc24c7e9d443244b8a2c9cf2eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:34e89912b6c20e0fd80e07fa401fd218a410aa1ce9f1c2f1dad6db1bd0ce0927", size = 1800333, upload-time = "2026-03-28T17:18:32.269Z" }, + { url = "https://files.pythonhosted.org/packages/84/43/9b2147a1df3559f49bd723e22905b46a46c068a53adb54abdca32c4de180/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0e217cf9f6a42908c52b46e42c568bd57adc39c9286ced31aaace614b6087965", size = 1820617, upload-time = "2026-03-28T17:18:35.238Z" }, + { url = "https://files.pythonhosted.org/packages/a9/7f/b3481a81e7a586d02e99387b18c6dafff41285f6efd3daa2124c01f87eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:0c296f1221e21ba979f5ac1964c3b78cfde15c5c5f855ffd2caab337e9cd9182", size = 1643417, upload-time = "2026-03-28T17:18:37.949Z" }, + { url = "https://files.pythonhosted.org/packages/8f/72/07181226bc99ce1124e0f89280f5221a82d3ae6a6d9d1973ce429d48e52b/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d99a9d168ebaffb74f36d011750e490085ac418f4db926cce3989c8fe6cb6b1b", size = 1849286, upload-time = "2026-03-28T17:18:40.534Z" }, + { url = "https://files.pythonhosted.org/packages/1a/e6/1b3566e103eca6da5be4ae6713e112a053725c584e96574caf117568ffef/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cb19177205d93b881f3f89e6081593676043a6828f59c78c17a0fd6c1fbed2ba", size = 1782635, upload-time = "2026-03-28T17:18:43.073Z" }, + { url = "https://files.pythonhosted.org/packages/37/58/1b11c71904b8d079eb0c39fe664180dd1e14bebe5608e235d8bfbadc8929/aiohttp-3.13.4-cp314-cp314t-win32.whl", hash = "sha256:c606aa5656dab6552e52ca368e43869c916338346bfaf6304e15c58fb113ea30", size = 472537, upload-time = "2026-03-28T17:18:46.286Z" }, + { url = "https://files.pythonhosted.org/packages/bc/8f/87c56a1a1977d7dddea5b31e12189665a140fdb48a71e9038ff90bb564ec/aiohttp-3.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:014dcc10ec8ab8db681f0d68e939d1e9286a5aa2b993cbbdb0db130853e02144", size = 506381, upload-time = "2026-03-28T17:18:48.74Z" }, ] [[package]] @@ -270,14 +270,14 @@ wheels = [ [[package]] name = "authlib" -version = "1.6.6" +version = "1.6.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bb/9b/b1661026ff24bc641b76b78c5222d614776b0c085bcfdac9bd15a1cb4b35/authlib-1.6.6.tar.gz", hash = "sha256:45770e8e056d0f283451d9996fbb59b70d45722b45d854d58f32878d0a40c38e", size = 164894, upload-time = "2025-12-12T08:01:41.464Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/98/00d3dd826d46959ad8e32af2dbb2398868fd9fd0683c26e56d0789bd0e68/authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04", size = 165134, upload-time = "2026-03-02T07:44:01.998Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/51/321e821856452f7386c4e9df866f196720b1ad0c5ea1623ea7399969ae3b/authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd", size = 244005, upload-time = "2025-12-12T08:01:40.209Z" }, + { url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" }, ] [[package]] @@ -935,67 +935,62 @@ toml = [ [[package]] name = "cryptography" -version = "46.0.3" +version = "46.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, - { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, - { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, - { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, - { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, - { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, - { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, - { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, - { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, - { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, - { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, - { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, - { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, - { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, - { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" }, - { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, - { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, - { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, - { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, - { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, - { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, - { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, - { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, - { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, - { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, - { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, - { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" }, - { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" }, - { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, - { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, - { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, - { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, - { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, - { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, - { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, - { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, - { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, - { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, - { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, - { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, - { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, - { url = "https://files.pythonhosted.org/packages/d9/cd/1a8633802d766a0fa46f382a77e096d7e209e0817892929655fe0586ae32/cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32", size = 3689163, upload-time = "2025-10-15T23:18:13.821Z" }, - { url = "https://files.pythonhosted.org/packages/4c/59/6b26512964ace6480c3e54681a9859c974172fb141c38df11eadd8416947/cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c", size = 3429474, upload-time = "2025-10-15T23:18:15.477Z" }, - { url = "https://files.pythonhosted.org/packages/06/8a/e60e46adab4362a682cf142c7dcb5bf79b782ab2199b0dcb81f55970807f/cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea", size = 3698132, upload-time = "2025-10-15T23:18:17.056Z" }, - { url = "https://files.pythonhosted.org/packages/da/38/f59940ec4ee91e93d3311f7532671a5cef5570eb04a144bf203b58552d11/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b", size = 4243992, upload-time = "2025-10-15T23:18:18.695Z" }, - { url = "https://files.pythonhosted.org/packages/b0/0c/35b3d92ddebfdfda76bb485738306545817253d0a3ded0bfe80ef8e67aa5/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb", size = 4409944, upload-time = "2025-10-15T23:18:20.597Z" }, - { url = "https://files.pythonhosted.org/packages/99/55/181022996c4063fc0e7666a47049a1ca705abb9c8a13830f074edb347495/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717", size = 4242957, upload-time = "2025-10-15T23:18:22.18Z" }, - { url = "https://files.pythonhosted.org/packages/ba/af/72cd6ef29f9c5f731251acadaeb821559fe25f10852f44a63374c9ca08c1/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9", size = 4409447, upload-time = "2025-10-15T23:18:24.209Z" }, - { url = "https://files.pythonhosted.org/packages/0d/c3/e90f4a4feae6410f914f8ebac129b9ae7a8c92eb60a638012dde42030a9d/cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c", size = 3438528, upload-time = "2025-10-15T23:18:26.227Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, + { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, + { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, + { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, + { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, + { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, + { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, + { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, + { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" }, + { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, + { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, + { url = "https://files.pythonhosted.org/packages/63/0c/dca8abb64e7ca4f6b2978769f6fea5ad06686a190cec381f0a796fdcaaba/cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", size = 3476879, upload-time = "2026-04-08T01:57:38.664Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", size = 4219700, upload-time = "2026-04-08T01:57:40.625Z" }, + { url = "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", size = 4385982, upload-time = "2026-04-08T01:57:42.725Z" }, + { url = "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", size = 4219115, upload-time = "2026-04-08T01:57:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", size = 4385479, upload-time = "2026-04-08T01:57:46.86Z" }, + { url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" }, ] [[package]] @@ -4896,7 +4891,7 @@ wheels = [ [[package]] name = "testcontainers" -version = "4.14.2" +version = "4.15.0rc1" source = { editable = "." } dependencies = [ { name = "docker" }, @@ -5144,7 +5139,7 @@ requires-dist = [ { name = "weaviate-client", marker = "extra == 'weaviate'", specifier = ">=4" }, { name = "wrapt" }, ] -provides-extras = ["arangodb", "aws", "azurite", "cassandra", "clickhouse", "cosmosdb", "cockroachdb", "db2", "elasticsearch", "generic", "test-module-import", "google", "influxdb", "k3s", "kafka", "keycloak", "localstack", "mailpit", "memcached", "minio", "milvus", "mongodb", "mqtt", "mssql", "mysql", "nats", "neo4j", "nginx", "openfga", "opensearch", "ollama", "oracle", "oracle-free", "postgres", "qdrant", "rabbitmq", "redis", "registry", "selenium", "scylla", "sftp", "vault", "weaviate", "chroma", "trino"] +provides-extras = ["arangodb", "aws", "azurite", "cassandra", "clickhouse", "cosmosdb", "cockroachdb", "db2", "elasticsearch", "generic", "test-module-import", "google", "influxdb", "k3s", "kafka", "keycloak", "localstack", "mailpit", "memcached", "minio", "milvus", "mongodb", "mqtt", "mssql", "mysql", "nats", "neo4j", "nginx", "openfga", "opensearch", "ollama", "oracle", "oracle-free", "postgres", "qdrant", "rabbitmq", "redis", "registry", "selenium", "scylla", "sftp", "valkey", "vault", "temporal", "weaviate", "chroma", "trino"] [package.metadata.requires-dev] dev = [