From 4e7e7878087c9861df198dc844e444b226170a4e Mon Sep 17 00:00:00 2001 From: David Ankin Date: Sun, 4 May 2025 14:16:24 -0400 Subject: [PATCH 1/5] fix: make ruff happy about TC006 * https://docs.astral.sh/ruff/rules/runtime-cast-value/#why-is-this-bad --- core/testcontainers/core/docker_client.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/core/testcontainers/core/docker_client.py b/core/testcontainers/core/docker_client.py index 52785253..07c7ef53 100644 --- a/core/testcontainers/core/docker_client.py +++ b/core/testcontainers/core/docker_client.py @@ -151,7 +151,7 @@ def find_host_network(self) -> Optional[str]: except ipaddress.AddressValueError: continue if docker_host in subnet: - return cast(str, network.name) + return cast("str", network.name) except (ipaddress.AddressValueError, OSError): pass return None @@ -163,7 +163,7 @@ def port(self, container_id: str, port: int) -> str: port_mappings = self.client.api.port(container_id, port) if not port_mappings: raise ConnectionError(f"Port mapping for container {container_id} and port {port} is not available") - return cast(str, port_mappings[0]["HostPort"]) + return cast("str", port_mappings[0]["HostPort"]) def get_container(self, container_id: str) -> dict[str, Any]: """ @@ -172,7 +172,7 @@ def get_container(self, container_id: str) -> dict[str, Any]: containers = self.client.api.containers(filters={"id": container_id}) if not containers: raise RuntimeError(f"Could not get container with id {container_id}") - return cast(dict[str, Any], containers[0]) + return cast("dict[str, Any]", containers[0]) def bridge_ip(self, container_id: str) -> str: """ @@ -241,7 +241,7 @@ def host(self) -> str: hostname = url.hostname if not hostname or (hostname == "localnpipe" and utils.is_windows()): return "localhost" - return cast(str, url.hostname) + return cast("str", url.hostname) if utils.inside_container() and ("unix" in url.scheme or "npipe" in url.scheme): ip_address = utils.default_gateway_ip() if ip_address: @@ -257,7 +257,7 @@ def login(self, auth_config: DockerAuthInfo) -> None: def client_networks_create(self, name: str, param: dict[str, Any]) -> dict[str, Any]: labels = create_labels("", param.get("labels")) - return cast(dict[str, Any], self.client.networks.create(name, **{**param, "labels": labels})) + return cast("dict[str, Any]", self.client.networks.create(name, **{**param, "labels": labels})) def get_docker_host() -> Optional[str]: From 62f345359ee19fbf220a0eccc90913941088f48d Mon Sep 17 00:00:00 2001 From: David Ankin Date: Sun, 4 May 2025 22:27:49 -0400 Subject: [PATCH 2/5] fix remaining mypy issues --- core/testcontainers/compose/__init__.py | 13 +++- core/testcontainers/compose/compose.py | 64 ++++++++++------- core/testcontainers/core/config.py | 4 +- core/testcontainers/core/container.py | 87 ++++++++++++++--------- core/testcontainers/core/docker_client.py | 9 ++- core/testcontainers/core/image.py | 49 ++++++++----- core/testcontainers/core/network.py | 29 ++++++-- core/testcontainers/core/waiting_utils.py | 22 +++--- core/testcontainers/socat/socat.py | 4 +- core/tests/conftest.py | 4 +- core/tests/test_compose.py | 10 +-- core/tests/test_config.py | 8 ++- core/tests/test_container.py | 2 +- core/tests/test_core_ports.py | 16 +++-- core/tests/test_core_registry.py | 2 +- core/tests/test_docker_in_docker.py | 7 +- core/tests/test_image.py | 15 ++-- core/tests/test_new_docker_api.py | 4 +- core/tests/test_ryuk.py | 8 ++- 19 files changed, 230 insertions(+), 127 deletions(-) diff --git a/core/testcontainers/compose/__init__.py b/core/testcontainers/compose/__init__.py index 8d16ca6f..8eb8e100 100644 --- a/core/testcontainers/compose/__init__.py +++ b/core/testcontainers/compose/__init__.py @@ -1,8 +1,15 @@ # flake8: noqa: F401 from testcontainers.compose.compose import ( ComposeContainer, - ContainerIsNotRunning, DockerCompose, - NoSuchPortExposed, - PublishedPort, + PublishedPortModel, ) +from testcontainers.core.exceptions import ContainerIsNotRunning, NoSuchPortExposed + +__all__ = [ + "ComposeContainer", + "ContainerIsNotRunning", + "DockerCompose", + "NoSuchPortExposed", + "PublishedPortModel", +] diff --git a/core/testcontainers/compose/compose.py b/core/testcontainers/compose/compose.py index 35ca5b33..d66734cc 100644 --- a/core/testcontainers/compose/compose.py +++ b/core/testcontainers/compose/compose.py @@ -7,6 +7,7 @@ from re import split from subprocess import CompletedProcess from subprocess import run as subprocess_run +from types import TracebackType from typing import Any, Callable, Literal, Optional, TypeVar, Union, cast from urllib.error import HTTPError, URLError from urllib.request import urlopen @@ -18,7 +19,7 @@ _WARNINGS = {"DOCKER_COMPOSE_GET_CONFIG": "get_config is experimental, see testcontainers/testcontainers-python#669"} -def _ignore_properties(cls: type[_IPT], dict_: any) -> _IPT: +def _ignore_properties(cls: type[_IPT], dict_: Any) -> _IPT: """omits extra fields like @JsonIgnoreProperties(ignoreUnknown = true) https://gist.github.com/alexanderankin/2a4549ac03554a31bef6eaaf2eaf7fd5""" @@ -30,23 +31,23 @@ def _ignore_properties(cls: type[_IPT], dict_: any) -> _IPT: @dataclass -class PublishedPort: +class PublishedPortModel: """ Class that represents the response we get from compose when inquiring status via `DockerCompose.get_running_containers()`. """ URL: Optional[str] = None - TargetPort: Optional[str] = None - PublishedPort: Optional[str] = None + TargetPort: Optional[int] = None + PublishedPort: Optional[int] = None Protocol: Optional[str] = None - def normalize(self): + def normalize(self) -> "PublishedPortModel": url_not_usable = system() == "Windows" and self.URL == "0.0.0.0" if url_not_usable: self_dict = asdict(self) self_dict.update({"URL": "127.0.0.1"}) - return PublishedPort(**self_dict) + return PublishedPortModel(**self_dict) return self @@ -75,19 +76,19 @@ class ComposeContainer: Service: Optional[str] = None State: Optional[str] = None Health: Optional[str] = None - ExitCode: Optional[str] = None - Publishers: list[PublishedPort] = field(default_factory=list) + ExitCode: Optional[int] = None + Publishers: list[PublishedPortModel] = field(default_factory=list) - def __post_init__(self): + def __post_init__(self) -> None: if self.Publishers: - self.Publishers = [_ignore_properties(PublishedPort, p) for p in self.Publishers] + self.Publishers = [_ignore_properties(PublishedPortModel, p) for p in self.Publishers] def get_publisher( self, by_port: Optional[int] = None, by_host: Optional[str] = None, - prefer_ip_version: Literal["IPV4", "IPv6"] = "IPv4", - ) -> PublishedPort: + prefer_ip_version: Literal["IPv4", "IPv6"] = "IPv4", + ) -> PublishedPortModel: remaining_publishers = self.Publishers remaining_publishers = [r for r in remaining_publishers if self._matches_protocol(prefer_ip_version, r)] @@ -109,8 +110,9 @@ def get_publisher( ) @staticmethod - def _matches_protocol(prefer_ip_version, r): - return (":" in r.URL) is (prefer_ip_version == "IPv6") + def _matches_protocol(prefer_ip_version: str, r: PublishedPortModel) -> bool: + r_url = r.URL + return (r_url is not None and ":" in r_url) is (prefer_ip_version == "IPv6") @dataclass @@ -164,7 +166,7 @@ class DockerCompose: image: "hello-world" """ - context: Union[str, PathLike] + context: Union[str, PathLike[str]] compose_file_name: Optional[Union[str, list[str]]] = None pull: bool = False build: bool = False @@ -175,7 +177,7 @@ class DockerCompose: docker_command_path: Optional[str] = None profiles: Optional[list[str]] = None - def __post_init__(self): + def __post_init__(self) -> None: if isinstance(self.compose_file_name, str): self.compose_file_name = [self.compose_file_name] @@ -183,7 +185,9 @@ def __enter__(self) -> "DockerCompose": self.start() return self - def __exit__(self, exc_type, exc_val, exc_tb) -> None: + def __exit__( + self, exc_type: Optional[type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType] + ) -> None: self.stop(not self.keep_volumes) def docker_compose_command(self) -> list[str]: @@ -235,7 +239,7 @@ def start(self) -> None: self._run_command(cmd=up_cmd) - def stop(self, down=True) -> None: + def stop(self, down: bool = True) -> None: """ Stops the docker compose environment. """ @@ -295,7 +299,7 @@ def get_config( cmd_output = self._run_command(cmd=config_cmd).stdout return cast(dict[str, Any], loads(cmd_output)) # noqa: TC006 - def get_containers(self, include_all=False) -> list[ComposeContainer]: + def get_containers(self, include_all: bool = False) -> list[ComposeContainer]: """ Fetch information about running containers via `docker compose ps --format json`. Available only in V2 of compose. @@ -370,17 +374,18 @@ def exec_in_container( """ if not service_name: service_name = self.get_container().Service - exec_cmd = [*self.compose_command_property, "exec", "-T", service_name, *command] + assert service_name + exec_cmd: list[str] = [*self.compose_command_property, "exec", "-T", service_name, *command] result = self._run_command(cmd=exec_cmd) - return (result.stdout.decode("utf-8"), result.stderr.decode("utf-8"), result.returncode) + return result.stdout.decode("utf-8"), result.stderr.decode("utf-8"), result.returncode def _run_command( self, cmd: Union[str, list[str]], context: Optional[str] = None, ) -> CompletedProcess[bytes]: - context = context or self.context + context = context or str(self.context) return subprocess_run( cmd, capture_output=True, @@ -392,7 +397,7 @@ def get_service_port( self, service_name: Optional[str] = None, port: Optional[int] = None, - ): + ) -> Optional[int]: """ Returns the mapped port for one of the services. @@ -408,13 +413,14 @@ def get_service_port( str: The mapped port on the host """ - return self.get_container(service_name).get_publisher(by_port=port).normalize().PublishedPort + normalize: PublishedPortModel = self.get_container(service_name).get_publisher(by_port=port).normalize() + return normalize.PublishedPort def get_service_host( self, service_name: Optional[str] = None, port: Optional[int] = None, - ): + ) -> Optional[str]: """ Returns the host for one of the services. @@ -430,13 +436,17 @@ def get_service_host( str: The hostname for the service """ - return self.get_container(service_name).get_publisher(by_port=port).normalize().URL + container: ComposeContainer = self.get_container(service_name) + publisher: PublishedPortModel = container.get_publisher(by_port=port) + normalize: PublishedPortModel = publisher.normalize() + url: Optional[str] = normalize.URL + return url def get_service_host_and_port( self, service_name: Optional[str] = None, port: Optional[int] = None, - ): + ) -> tuple[Optional[str], Optional[int]]: publisher = self.get_container(service_name).get_publisher(by_port=port).normalize() return publisher.URL, publisher.PublishedPort diff --git a/core/testcontainers/core/config.py b/core/testcontainers/core/config.py index f3aa337e..be000ef9 100644 --- a/core/testcontainers/core/config.py +++ b/core/testcontainers/core/config.py @@ -4,7 +4,7 @@ from os import environ from os.path import exists from pathlib import Path -from typing import Optional, Union +from typing import Optional, Union, cast import docker @@ -36,6 +36,7 @@ def get_docker_socket() -> str: client = docker.from_env() try: socket_path = client.api.get_adapter(client.api.base_url).socket_path + socket_path = cast("str", socket_path) # return the normalized path as string return str(Path(socket_path).absolute()) except AttributeError: @@ -145,5 +146,6 @@ def timeout(self) -> int: "SLEEP_TIME", "TIMEOUT", # Public API of this module: + "ConnectionMode", "testcontainers_config", ] diff --git a/core/testcontainers/core/container.py b/core/testcontainers/core/container.py index 74f7828e..ca3761f5 100644 --- a/core/testcontainers/core/container.py +++ b/core/testcontainers/core/container.py @@ -1,10 +1,12 @@ import contextlib from os import PathLike from socket import socket -from typing import TYPE_CHECKING, Optional, Union +from types import TracebackType +from typing import TYPE_CHECKING, Any, Optional, TypedDict, Union, cast import docker.errors from docker import version +from docker.models.containers import ExecResult from docker.types import EndpointConfig from dotenv import dotenv_values from typing_extensions import Self, assert_never @@ -24,6 +26,11 @@ logger = setup_logger(__name__) +class Mount(TypedDict): + bind: str + mode: str + + class DockerContainer: """ Basic container object to spin up Docker instances. @@ -40,17 +47,17 @@ class DockerContainer: def __init__( self, image: str, - docker_client_kw: Optional[dict] = None, - **kwargs, + docker_client_kw: Optional[dict[str, Any]] = None, + **kwargs: Any, ) -> None: - self.env = {} - self.ports = {} - self.volumes = {} + self.env: dict[str, str] = {} + self.ports: dict[Union[str, int], Optional[Union[str, int]]] = {} + self.volumes: dict[str, Mount] = {} self.image = image self._docker = DockerClient(**(docker_client_kw or {})) - self._container = None - self._command = None - self._name = None + self._container: Optional[Container] = None + self._command: Optional[Union[str, list[str]]] = None + self._name: Optional[str] = None self._network: Optional[Network] = None self._network_aliases: Optional[list[str]] = None self._kwargs = kwargs @@ -62,6 +69,7 @@ def with_env(self, key: str, value: str) -> Self: def with_env_file(self, env_file: Union[str, PathLike]) -> Self: env_values = dotenv_values(env_file) for key, value in env_values.items(): + assert value is not None self.with_env(key, value) return self @@ -105,11 +113,11 @@ def with_network(self, network: Network) -> Self: self._network = network return self - def with_network_aliases(self, *aliases) -> Self: - self._network_aliases = aliases + def with_network_aliases(self, *aliases: str) -> Self: + self._network_aliases = list(aliases) return self - def with_kwargs(self, **kwargs) -> Self: + def with_kwargs(self, **kwargs: Any) -> Self: self._kwargs = kwargs return self @@ -142,17 +150,16 @@ def start(self) -> Self: command=self._command, detach=True, environment=self.env, - ports=self.ports, + ports=cast("dict[int, Optional[int]]", self.ports), name=self._name, volumes=self.volumes, - **network_kwargs, - **self._kwargs, + **{**network_kwargs, **self._kwargs}, ) logger.info("Container started: %s", self._container.short_id) return self - def stop(self, force=True, delete_volume=True) -> None: + def stop(self, force: bool = True, delete_volume: bool = True) -> None: if self._container: self._container.remove(force=force, v=delete_volume) self.get_docker_client().client.close() @@ -160,18 +167,25 @@ def stop(self, force=True, delete_volume=True) -> None: def __enter__(self) -> Self: return self.start() - def __exit__(self, exc_type, exc_val, exc_tb) -> None: + def __exit__( + self, exc_type: Optional[type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType] + ) -> None: self.stop() def get_container_host_ip(self) -> str: connection_mode: ConnectionMode connection_mode = self.get_docker_client().get_connection_mode() + + # mypy: + container = self._container + assert container is not None + if connection_mode == ConnectionMode.docker_host: return self.get_docker_client().host() elif connection_mode == ConnectionMode.gateway_ip: - return self.get_docker_client().gateway_ip(self._container.id) + return self.get_docker_client().gateway_ip(container.id) elif connection_mode == ConnectionMode.bridge_ip: - return self.get_docker_client().bridge_ip(self._container.id) + return self.get_docker_client().bridge_ip(container.id) else: # ensure that we covered all possible connection_modes assert_never(connection_mode) @@ -179,7 +193,9 @@ def get_container_host_ip(self) -> str: @wait_container_is_ready() def get_exposed_port(self, port: int) -> int: if self.get_docker_client().get_connection_mode().use_mapped_port: - return self.get_docker_client().port(self._container.id, port) + c = self._container + assert c is not None + return int(self.get_docker_client().port(c.id, port)) return port def with_command(self, command: Union[str, list[str]]) -> Self: @@ -190,9 +206,9 @@ def with_name(self, name: str) -> Self: self._name = name return self - def with_volume_mapping(self, host: str, container: str, mode: str = "ro") -> Self: - mapping = {"bind": container, "mode": mode} - self.volumes[host] = mapping + def with_volume_mapping(self, host: Union[str, PathLike[str]], container: str, mode: str = "ro") -> Self: + mapping: Mount = {"bind": container, "mode": mode} + self.volumes[str(host)] = mapping return self def get_wrapped_container(self) -> "Container": @@ -206,7 +222,7 @@ def get_logs(self) -> tuple[bytes, bytes]: raise ContainerStartException("Container should be started before getting logs") return self._container.logs(stderr=False), self._container.logs(stdout=False) - def exec(self, command: Union[str, list[str]]) -> tuple[int, bytes]: + def exec(self, command: Union[str, list[str]]) -> ExecResult: if not self._container: raise ContainerStartException("Container should be started before executing a command") return self._container.exec_run(command) @@ -255,22 +271,27 @@ def _create_instance(cls) -> "Reaper": .with_env("RYUK_RECONNECTION_TIMEOUT", c.ryuk_reconnection_timeout) .start() ) - wait_for_logs(Reaper._container, r".* Started!", timeout=20, raise_on_exit=True) + rc = Reaper._container + assert rc is not None + wait_for_logs(rc, r".* Started!", timeout=20, raise_on_exit=True) - container_host = Reaper._container.get_container_host_ip() - container_port = int(Reaper._container.get_exposed_port(8080)) + container_host = rc.get_container_host_ip() + container_port = int(rc.get_exposed_port(8080)) if not container_host or not container_port: + rcc = rc._container + assert rcc raise ContainerConnectException( - f"Could not obtain network details for {Reaper._container._container.id}. Host: {container_host} Port: {container_port}" + f"Could not obtain network details for {rcc.id}. Host: {container_host} Port: {container_port}" ) last_connection_exception: Optional[Exception] = None for _ in range(50): try: - Reaper._socket = socket() - Reaper._socket.settimeout(1) - Reaper._socket.connect((container_host, container_port)) + s = socket() + Reaper._socket = s + s.settimeout(1) + s.connect((container_host, container_port)) last_connection_exception = None break except (ConnectionRefusedError, OSError) as e: @@ -286,7 +307,9 @@ def _create_instance(cls) -> "Reaper": if last_connection_exception: raise last_connection_exception - Reaper._socket.send(f"label={LABEL_SESSION_ID}={SESSION_ID}\r\n".encode()) + rs = Reaper._socket + assert rs is not None + rs.send(f"label={LABEL_SESSION_ID}={SESSION_ID}\r\n".encode()) Reaper._instance = Reaper() diff --git a/core/testcontainers/core/docker_client.py b/core/testcontainers/core/docker_client.py index 07c7ef53..a8615066 100644 --- a/core/testcontainers/core/docker_client.py +++ b/core/testcontainers/core/docker_client.py @@ -19,7 +19,7 @@ import urllib import urllib.parse from collections.abc import Iterable -from typing import Any, Callable, Optional, TypeVar, Union, cast +from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar, Union, cast import docker from docker.models.containers import Container, ContainerCollection @@ -32,6 +32,9 @@ from testcontainers.core.config import testcontainers_config as c from testcontainers.core.labels import SESSION_ID, create_labels +if TYPE_CHECKING: + from docker.models.networks import Network as DockerNetwork + LOGGER = utils.setup_logger(__name__) _P = ParamSpec("_P") @@ -255,9 +258,9 @@ def login(self, auth_config: DockerAuthInfo) -> None: login_info = self.client.login(**auth_config._asdict()) LOGGER.debug(f"logged in using {login_info}") - def client_networks_create(self, name: str, param: dict[str, Any]) -> dict[str, Any]: + def client_networks_create(self, name: str, param: dict[str, Any]) -> "DockerNetwork": labels = create_labels("", param.get("labels")) - return cast("dict[str, Any]", self.client.networks.create(name, **{**param, "labels": labels})) + return self.client.networks.create(name, **{**param, "labels": labels}) def get_docker_host() -> Optional[str]: diff --git a/core/testcontainers/core/image.py b/core/testcontainers/core/image.py index 27696619..4683ef61 100644 --- a/core/testcontainers/core/image.py +++ b/core/testcontainers/core/image.py @@ -1,5 +1,6 @@ from os import PathLike -from typing import TYPE_CHECKING, Optional, Union +from types import TracebackType +from typing import TYPE_CHECKING, Any, Optional, Union from typing_extensions import Self @@ -7,7 +8,9 @@ from testcontainers.core.utils import setup_logger if TYPE_CHECKING: - from docker.models.containers import Image + from collections.abc import Iterable + + from docker.models.images import Image logger = setup_logger(__name__) @@ -34,21 +37,21 @@ class DockerImage: def __init__( self, - path: Union[str, PathLike], - docker_client_kw: Optional[dict] = None, + path: Union[str, PathLike[str]], + docker_client_kw: Optional[dict[str, Any]] = None, tag: Optional[str] = None, clean_up: bool = True, - dockerfile_path: Union[str, PathLike] = "Dockerfile", + dockerfile_path: Union[str, PathLike[str]] = "Dockerfile", no_cache: bool = False, - **kwargs, + **kwargs: Any, ) -> None: self.tag = tag self.path = path self._docker = DockerClient(**(docker_client_kw or {})) self.clean_up = clean_up self._kwargs = kwargs - self._image = None - self._logs = None + self._image: Optional[Image] = None + self._logs: Optional[Iterable[dict[str, Any]]] = None self._dockerfile_path = dockerfile_path self._no_cache = no_cache @@ -56,7 +59,10 @@ def build(self) -> Self: logger.info(f"Building image from {self.path}") docker_client = self.get_docker_client() self._image, self._logs = docker_client.build( - path=str(self.path), tag=self.tag, dockerfile=self._dockerfile_path, nocache=self._no_cache, **self._kwargs + path=str(self.path), + dockerfile=self._dockerfile_path, + nocache=self._no_cache, + **{**({"tag": self.tag} if self.tag else {}), **self._kwargs}, ) logger.info(f"Built image {self.short_id} with tag {self.tag}") return self @@ -66,11 +72,15 @@ def short_id(self) -> str: """ The ID of the image truncated to 12 characters, without the ``sha256:`` prefix. """ - if self._image.id.startswith("sha256:"): - return self._image.id.split(":")[1][:12] - return self._image.id[:12] - - def remove(self, force=True, noprune=False) -> None: + i = self._image + assert i + i_id = i.id + assert isinstance(i_id, str) + if i_id.startswith("sha256:"): + return i_id.split(":")[1][:12] + return i_id[:12] + + def remove(self, force: bool = True, noprune: bool = False) -> None: """ Remove the image. @@ -88,7 +98,9 @@ def __str__(self) -> str: def __enter__(self) -> Self: return self.build() - def __exit__(self, exc_type, exc_val, exc_tb) -> None: + def __exit__( + self, exc_type: Optional[type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType] + ) -> None: self.remove() def get_wrapped_image(self) -> "Image": @@ -97,5 +109,8 @@ def get_wrapped_image(self) -> "Image": def get_docker_client(self) -> DockerClient: return self._docker - def get_logs(self) -> list[dict]: - return list(self._logs) + def get_logs(self) -> list[dict[str, Any]]: + logs = self._logs + if logs is None: + return [] + return list(logs) diff --git a/core/testcontainers/core/network.py b/core/testcontainers/core/network.py index b9bd670f..f602a6ea 100644 --- a/core/testcontainers/core/network.py +++ b/core/testcontainers/core/network.py @@ -11,10 +11,14 @@ # License for the specific language governing permissions and limitations # under the License. import uuid -from typing import Any, Optional +from types import TracebackType +from typing import TYPE_CHECKING, Any, Optional from testcontainers.core.docker_client import DockerClient +if TYPE_CHECKING: + from docker.models.networks import Network as DockerNetwork + class Network: """ @@ -27,20 +31,35 @@ def __init__( self.name = str(uuid.uuid4()) self._docker = DockerClient(**(docker_client_kw or {})) self._docker_network_kw = docker_network_kw or {} + self._network: Optional[DockerNetwork] = None + + @property + def _unwrap_network(self) -> "DockerNetwork": + s_n = self._network + assert s_n is not None + return s_n + + @property + def id(self) -> Optional[str]: + network_id = self._unwrap_network.id + if isinstance(network_id, str): + return network_id + return None def connect(self, container_id: str, network_aliases: Optional[list[str]] = None) -> None: - self._network.connect(container_id, aliases=network_aliases) + self._unwrap_network.connect(container_id, aliases=network_aliases) def remove(self) -> None: - self._network.remove() + self._unwrap_network.remove() def create(self) -> "Network": self._network = self._docker.client_networks_create(self.name, self._docker_network_kw) - self.id = self._network.id return self def __enter__(self) -> "Network": return self.create() - def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore[no-untyped-def] + def __exit__( + self, exc_type: Optional[type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType] + ) -> None: self.remove() diff --git a/core/testcontainers/core/waiting_utils.py b/core/testcontainers/core/waiting_utils.py index 0d531b15..c760de41 100644 --- a/core/testcontainers/core/waiting_utils.py +++ b/core/testcontainers/core/waiting_utils.py @@ -15,7 +15,7 @@ import re import time import traceback -from typing import TYPE_CHECKING, Any, Callable, Union +from typing import TYPE_CHECKING, Any, Callable, Union, cast import wrapt @@ -31,7 +31,7 @@ TRANSIENT_EXCEPTIONS = (TimeoutError, ConnectionError) -def wait_container_is_ready(*transient_exceptions) -> Callable: +def wait_container_is_ready(*transient_exceptions: type[BaseException]) -> Callable[..., Any]: """ Wait until container is ready. @@ -45,7 +45,7 @@ def wait_container_is_ready(*transient_exceptions) -> Callable: transient_exceptions = TRANSIENT_EXCEPTIONS + tuple(transient_exceptions) @wrapt.decorator - def wrapper(wrapped: Callable, instance: Any, args: list, kwargs: dict) -> Any: + def wrapper(wrapped: Callable[..., Any], instance: Any, args: list[Any], kwargs: dict[str, Any]) -> Any: from testcontainers.core.container import DockerContainer if isinstance(instance, DockerContainer): @@ -69,7 +69,7 @@ def wrapper(wrapped: Callable, instance: Any, args: list, kwargs: dict) -> Any: f"{kwargs}). Exception: {exception}" ) - return wrapper + return cast("Callable[..., Any]", wrapper) @wait_container_is_ready() @@ -82,7 +82,7 @@ def wait_for(condition: Callable[..., bool]) -> bool: def wait_for_logs( container: "DockerContainer", - predicate: Union[Callable, str], + predicate: Union[Callable[..., bool], str], timeout: float = config.timeout, interval: float = 1, predicate_streams_and: bool = False, @@ -105,18 +105,18 @@ def wait_for_logs( duration: Number of seconds until the predicate was satisfied. """ if isinstance(predicate, str): - predicate = re.compile(predicate, re.MULTILINE).search + re_predicate = re.compile(predicate, re.MULTILINE).search wrapped = container.get_wrapped_container() start = time.time() while True: duration = time.time() - start - stdout, stderr = container.get_logs() - stdout = stdout.decode() - stderr = stderr.decode() + stdout_b, stderr_b = container.get_logs() + stdout = stdout_b.decode() + stderr = stderr_b.decode() predicate_result = ( - predicate(stdout) or predicate(stderr) + re_predicate(stdout) or re_predicate(stderr) if predicate_streams_and is False - else predicate(stdout) and predicate(stderr) + else re_predicate(stdout) and re_predicate(stderr) # ) if predicate_result: diff --git a/core/testcontainers/socat/socat.py b/core/testcontainers/socat/socat.py index d093e69f..cc54f924 100644 --- a/core/testcontainers/socat/socat.py +++ b/core/testcontainers/socat/socat.py @@ -13,7 +13,7 @@ import random import socket import string -from typing import Optional +from typing import Any, Optional from testcontainers.core.container import DockerContainer from testcontainers.core.waiting_utils import wait_container_is_ready @@ -27,7 +27,7 @@ class SocatContainer(DockerContainer): def __init__( self, image: str = "alpine/socat:1.7.4.3-r0", - **kwargs, + **kwargs: Any, ) -> None: """ Initialize a new SocatContainer with the given image. diff --git a/core/tests/conftest.py b/core/tests/conftest.py index cbacddc9..0ba178a5 100644 --- a/core/tests/conftest.py +++ b/core/tests/conftest.py @@ -2,7 +2,7 @@ import pytest from typing import Callable -from testcontainers.core.container import DockerClient +from testcontainers.core.docker_client import DockerClient from pprint import pprint import sys @@ -54,7 +54,7 @@ def _check_for_image(image_short_id: str, cleaned: bool) -> None: @pytest.fixture -def show_container_attributes() -> None: +def show_container_attributes() -> Callable[..., None]: """Wrap the show_container_attributes function in a fixture""" def _show_container_attributes(container_id: str) -> None: diff --git a/core/tests/test_compose.py b/core/tests/test_compose.py index 9279ce3f..755b8b17 100644 --- a/core/tests/test_compose.py +++ b/core/tests/test_compose.py @@ -8,7 +8,8 @@ import pytest from pytest_mock import MockerFixture -from testcontainers.compose import DockerCompose, ContainerIsNotRunning, NoSuchPortExposed +from testcontainers.compose import DockerCompose +from testcontainers.core.exceptions import ContainerIsNotRunning, NoSuchPortExposed FIXTURES = Path(__file__).parent.joinpath("compose_fixtures") @@ -146,7 +147,7 @@ def test_compose_logs(): # either the line is blank or the first column (|-separated) contains the service name # this is a safe way to split the string # docker changes the prefix between versions 24 and 25 - assert not line or container.Service in next(iter(line.split("|")), None) + assert not line or container.Service in next(iter(line.split("|"))) def test_compose_volumes(): @@ -196,10 +197,11 @@ def test_compose_multiple_containers_and_ports(): e.match("get_container failed") e.match("not exactly 1 container") - assert multiple.get_container("alpine") - assert multiple.get_container("alpine2") + multiple.get_container("alpine") + multiple.get_container("alpine2") a2p = multiple.get_service_port("alpine2") + assert a2p is not None assert a2p > 0 # > 1024 with pytest.raises(NoSuchPortExposed) as e: diff --git a/core/tests/test_config.py b/core/tests/test_config.py index 845ca7ac..00f2ebc3 100644 --- a/core/tests/test_config.py +++ b/core/tests/test_config.py @@ -81,8 +81,12 @@ def test_invalid_connection_mode(monkeypatch: pytest.MonkeyPatch) -> None: @pytest.mark.parametrize("mode, use_mapped", (("bridge_ip", False), ("gateway_ip", True), ("docker_host", True))) def test_valid_connection_mode(monkeypatch: pytest.MonkeyPatch, mode: str, use_mapped: bool) -> None: monkeypatch.setenv("TESTCONTAINERS_CONNECTION_MODE", mode) - assert get_user_overwritten_connection_mode().use_mapped_port is use_mapped - assert TestcontainersConfiguration().connection_mode_override.use_mapped_port is use_mapped + uo_cmo = get_user_overwritten_connection_mode() + assert uo_cmo + assert uo_cmo.use_mapped_port is use_mapped + cmo = TestcontainersConfiguration().connection_mode_override + assert cmo + assert cmo.use_mapped_port is use_mapped def test_no_connection_mode_given(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/core/tests/test_container.py b/core/tests/test_container.py index e1e7cff7..83aaf3a5 100644 --- a/core/tests/test_container.py +++ b/core/tests/test_container.py @@ -58,7 +58,7 @@ def fake_for_mode(*container_id: str): def test_get_exposed_port_mapped( container: DockerContainer, monkeypatch: pytest.MonkeyPatch, mode: ConnectionMode ) -> None: - def fake_mapped(container_id: int, port: int) -> int: + def fake_mapped(container_id: str, port: int) -> int: assert container_id == FAKE_ID assert port == 8080 return 45678 diff --git a/core/tests/test_core_ports.py b/core/tests/test_core_ports.py index 148ddf08..29fbce1f 100644 --- a/core/tests/test_core_ports.py +++ b/core/tests/test_core_ports.py @@ -1,5 +1,5 @@ import pytest -from typing import Union, Optional +from typing import Any, Union, Optional from testcontainers.core.container import DockerContainer from docker.errors import APIError @@ -26,8 +26,10 @@ def test_docker_container_with_bind_ports(container_port: Union[str, int], host_ container.start() # prepare to inspect container - container_id = container._container.id - client = container._container.client + c_c = container._container + assert c_c + container_id = c_c.id + client = c_c.client # assemble expected output to compare to container API container_port = str(container_port) @@ -73,13 +75,15 @@ def test_error_docker_container_with_bind_ports(container_port: Union[str, int], (("9001", 9002, "9003/udp", 9004), {"9001/tcp": {}, "9002/tcp": {}, "9003/udp": {}, "9004/tcp": {}}), ], ) -def test_docker_container_with_exposed_ports(ports: tuple[Union[str, int], ...], expected: dict): +def test_docker_container_with_exposed_ports(ports: tuple[Union[str, int], ...], expected: dict[str, Any]): container = DockerContainer("alpine:latest") container.with_exposed_ports(*ports) container.start() - container_id = container._container.id - client = container._container.client + c_c = container._container + assert c_c + container_id = c_c.id + client = c_c.client assert client.containers.get(container_id).attrs["Config"]["ExposedPorts"] == expected container.stop() diff --git a/core/tests/test_core_registry.py b/core/tests/test_core_registry.py index 384b0669..6188cf88 100644 --- a/core/tests/test_core_registry.py +++ b/core/tests/test_core_registry.py @@ -76,7 +76,7 @@ def test_with_private_registry(image, tag, username, password, monkeypatch): # Test a container with image from private registry with DockerContainer(f"{registry_url}/{image}:{tag}") as test_container: - wait_container_is_ready(test_container) + wait_container_is_ready()(test_container) # cleanup client.images.remove(f"{registry_url}/{image}:{tag}") diff --git a/core/tests/test_docker_in_docker.py b/core/tests/test_docker_in_docker.py index b07f80e9..d5df3045 100644 --- a/core/tests/test_docker_in_docker.py +++ b/core/tests/test_docker_in_docker.py @@ -4,9 +4,10 @@ import time import socket from pathlib import Path -from typing import Final, Any +from typing import Final, Any, Generator import pytest +from docker.models.containers import Container from testcontainers.core import utils from testcontainers.core.config import testcontainers_config as tcc @@ -18,7 +19,7 @@ from testcontainers.core.waiting_utils import wait_for_logs -def _wait_for_dind_return_ip(client, dind): +def _wait_for_dind_return_ip(client: DockerClient, dind: Container): # get ip address for DOCKER_HOST # avoiding DockerContainer class here to prevent code changes affecting the test docker_host_ip = client.bridge_ip(dind.id) @@ -101,7 +102,7 @@ def test_dind_inherits_network(): @contextlib.contextmanager -def print_surround_header(what: str, header_len: int = 80) -> None: +def print_surround_header(what: str, header_len: int = 80) -> Generator[None, None, None]: """ Helper to visually mark a block with headers """ diff --git a/core/tests/test_image.py b/core/tests/test_image.py index bff49618..655e6858 100644 --- a/core/tests/test_image.py +++ b/core/tests/test_image.py @@ -4,7 +4,7 @@ import os from pathlib import Path -from typing import Optional +from typing import Any, Optional from testcontainers.core.container import DockerContainer from testcontainers.core.image import DockerImage @@ -33,7 +33,9 @@ def test_docker_image(test_image_tag: Optional[str], test_cleanup: bool, check_f assert logs[0] == {"stream": "Step 1/2 : FROM alpine:latest"} assert logs[3] == {"stream": f'Step 2/2 : CMD echo "{random_string}"'} with DockerContainer(str(image)) as container: - assert container._container.image.short_id.endswith(image_short_id), "Image ID mismatch" + c_c = container._container + assert c_c + assert c_c.image.short_id.endswith(image_short_id), "Image ID mismatch" assert container.get_logs() == ((random_string + "\n").encode(), b""), "Container logs mismatch" check_for_image(image_short_id, test_cleanup) @@ -43,6 +45,7 @@ def test_docker_image(test_image_tag: Optional[str], test_cleanup: bool, check_f def test_docker_image_with_custom_dockerfile_path(dockerfile_path: Optional[Path]) -> None: with tempfile.TemporaryDirectory() as temp_directory: temp_dir_path = Path(temp_directory) + dockerfile_kwargs: dict[str, Any] = {} if dockerfile_path: os.makedirs(temp_dir_path / dockerfile_path.parent, exist_ok=True) dockerfile_rel_path = dockerfile_path @@ -62,7 +65,9 @@ def test_docker_image_with_custom_dockerfile_path(dockerfile_path: Optional[Path image_short_id = image.short_id assert image.get_wrapped_image() is not None with DockerContainer(str(image)) as container: - assert container._container.image.short_id.endswith(image_short_id), "Image ID mismatch" + c_c = container._container + assert c_c + assert c_c.image.short_id.endswith(image_short_id), "Image ID mismatch" assert container.get_logs() == (("Hello world!\n").encode(), b""), "Container logs mismatch" @@ -83,5 +88,7 @@ def test_docker_image_with_kwargs(): image_short_id = image.short_id assert image.get_wrapped_image() is not None with DockerContainer(str(image)) as container: - assert container._container.image.short_id.endswith(image_short_id), "Image ID mismatch" + c_c = container._container + assert c_c + assert c_c.image.short_id.endswith(image_short_id), "Image ID mismatch" assert container.get_logs() == (("new_arg\n").encode(), b""), "Container logs mismatch" diff --git a/core/tests/test_new_docker_api.py b/core/tests/test_new_docker_api.py index 936efc82..26a79aa9 100644 --- a/core/tests/test_new_docker_api.py +++ b/core/tests/test_new_docker_api.py @@ -21,7 +21,9 @@ def test_docker_kwargs(): container_second = DockerContainer("nginx:latest") with container_first: - container_second.with_kwargs(volumes_from=[container_first._container.short_id]) + cf_c = container_first._container + assert cf_c is not None + container_second.with_kwargs(volumes_from=[cf_c.short_id]) with container_second: files_first = container_first.exec("ls /code").output.decode("utf-8").strip() files_second = container_second.exec("ls /code").output.decode("utf-8").strip() diff --git a/core/tests/test_ryuk.py b/core/tests/test_ryuk.py index 5d6b208a..6640c8a0 100644 --- a/core/tests/test_ryuk.py +++ b/core/tests/test_ryuk.py @@ -21,14 +21,18 @@ def test_wait_for_reaper(monkeypatch: MonkeyPatch): docker_client = container.get_docker_client().client container_id = container.get_wrapped_container().short_id - reaper_id = Reaper._container.get_wrapped_container().short_id + rc = Reaper._container + assert rc + reaper_id = rc.get_wrapped_container().short_id assert docker_client.containers.get(container_id) is not None assert docker_client.containers.get(reaper_id) is not None wait_for_logs(container, "Hello from Docker!") - Reaper._socket.close() + rs = Reaper._socket + assert rs + rs.close() sleep(0.6) # Sleep until Ryuk reaps all dangling containers. 0.5 extra seconds for good measure. From 50647946449aff0c39a3d15696cb33021adc6cf6 Mon Sep 17 00:00:00 2001 From: Roy Moore Date: Mon, 5 May 2025 09:18:12 +0000 Subject: [PATCH 3/5] more fixes --- core/testcontainers/compose/compose.py | 6 ++++-- core/testcontainers/core/container.py | 2 +- core/testcontainers/core/waiting_utils.py | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/core/testcontainers/compose/compose.py b/core/testcontainers/compose/compose.py index d66734cc..c200ade1 100644 --- a/core/testcontainers/compose/compose.py +++ b/core/testcontainers/compose/compose.py @@ -1,4 +1,4 @@ -from dataclasses import asdict, dataclass, field, fields +from dataclasses import asdict, dataclass, field, fields, is_dataclass from functools import cached_property from json import loads from logging import warning @@ -25,9 +25,11 @@ def _ignore_properties(cls: type[_IPT], dict_: Any) -> _IPT: 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) + return cast("_IPT", cls(**filtered)) @dataclass diff --git a/core/testcontainers/core/container.py b/core/testcontainers/core/container.py index ca3761f5..9dbaf65b 100644 --- a/core/testcontainers/core/container.py +++ b/core/testcontainers/core/container.py @@ -66,7 +66,7 @@ def with_env(self, key: str, value: str) -> Self: self.env[key] = value return self - def with_env_file(self, env_file: Union[str, PathLike]) -> Self: + def with_env_file(self, env_file: Union[str, PathLike[str]]) -> Self: env_values = dotenv_values(env_file) for key, value in env_values.items(): assert value is not None diff --git a/core/testcontainers/core/waiting_utils.py b/core/testcontainers/core/waiting_utils.py index c760de41..c13c123e 100644 --- a/core/testcontainers/core/waiting_utils.py +++ b/core/testcontainers/core/waiting_utils.py @@ -44,7 +44,7 @@ def wait_container_is_ready(*transient_exceptions: type[BaseException]) -> Calla """ transient_exceptions = TRANSIENT_EXCEPTIONS + tuple(transient_exceptions) - @wrapt.decorator + @wrapt.decorator # type: ignore[misc] def wrapper(wrapped: Callable[..., Any], instance: Any, args: list[Any], kwargs: dict[str, Any]) -> Any: from testcontainers.core.container import DockerContainer From b83bc1120502a33fae7b1ac242611550782bdc02 Mon Sep 17 00:00:00 2001 From: Roy Moore Date: Mon, 5 May 2025 13:15:30 +0000 Subject: [PATCH 4/5] fix: wait_for_logs --- core/testcontainers/core/waiting_utils.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/core/testcontainers/core/waiting_utils.py b/core/testcontainers/core/waiting_utils.py index c13c123e..a18252f1 100644 --- a/core/testcontainers/core/waiting_utils.py +++ b/core/testcontainers/core/waiting_utils.py @@ -15,7 +15,7 @@ import re import time import traceback -from typing import TYPE_CHECKING, Any, Callable, Union, cast +from typing import TYPE_CHECKING, Any, Callable, Optional, Union, cast import wrapt @@ -104,8 +104,14 @@ def wait_for_logs( Returns: duration: Number of seconds until the predicate was satisfied. """ + re_predicate: Optional[Callable[[str], Any]] = None if isinstance(predicate, str): re_predicate = re.compile(predicate, re.MULTILINE).search + elif callable(predicate): + # some modules like mysql sends the search directly to the predicate + re_predicate = predicate + else: + raise TypeError("Predicate must be a string or callable") wrapped = container.get_wrapped_container() start = time.time() while True: From b1e5130c686602bddb7f2c358224fd0d1f4affa0 Mon Sep 17 00:00:00 2001 From: Roy Moore Date: Mon, 5 May 2025 13:41:33 +0000 Subject: [PATCH 5/5] fix: image build --- core/testcontainers/core/docker_client.py | 4 +++- core/testcontainers/core/image.py | 5 +---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/core/testcontainers/core/docker_client.py b/core/testcontainers/core/docker_client.py index a8615066..bf7b506c 100644 --- a/core/testcontainers/core/docker_client.py +++ b/core/testcontainers/core/docker_client.py @@ -115,7 +115,9 @@ def run( return container @_wrapped_image_collection - def build(self, path: str, tag: str, rm: bool = True, **kwargs: Any) -> tuple[Image, Iterable[dict[str, Any]]]: + def build( + self, path: str, tag: Optional[str], rm: bool = True, **kwargs: Any + ) -> tuple[Image, Iterable[dict[str, Any]]]: """ Build a Docker image from a directory containing the Dockerfile. diff --git a/core/testcontainers/core/image.py b/core/testcontainers/core/image.py index 4683ef61..eedb2ce4 100644 --- a/core/testcontainers/core/image.py +++ b/core/testcontainers/core/image.py @@ -59,10 +59,7 @@ def build(self) -> Self: logger.info(f"Building image from {self.path}") docker_client = self.get_docker_client() self._image, self._logs = docker_client.build( - path=str(self.path), - dockerfile=self._dockerfile_path, - nocache=self._no_cache, - **{**({"tag": self.tag} if self.tag else {}), **self._kwargs}, + path=str(self.path), tag=self.tag, dockerfile=self._dockerfile_path, nocache=self._no_cache, **self._kwargs ) logger.info(f"Built image {self.short_id} with tag {self.tag}") return self