Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@

intersphinx_mapping = {
"python": ("https://docs.python.org/3", None),
"selenium": ("https://seleniumhq.github.io/selenium/docs/api/py/", None),
"selenium": ("https://www.selenium.dev/selenium/docs/api/py/", None),
"typing_extensions": ("https://typing-extensions.readthedocs.io/en/latest/", None),
}

Expand Down
2 changes: 2 additions & 0 deletions core/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ Testcontainers Core

.. autoclass:: testcontainers.core.generic.DbContainer

.. autoclass:: testcontainers.core.wait_strategies.WaitStrategy

.. raw:: html

<hr>
Expand Down
126 changes: 113 additions & 13 deletions core/testcontainers/compose/compose.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
from dataclasses import asdict, dataclass, field, fields, is_dataclass
from functools import cached_property
from json import loads
from logging import warning
from logging import getLogger, warning
from os import PathLike
from platform import system
from re import split
from subprocess import CompletedProcess
from subprocess import CalledProcessError, 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

from testcontainers.core.exceptions import ContainerIsNotRunning, NoSuchPortExposed
from testcontainers.core.waiting_utils import wait_container_is_ready
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)
Expand Down Expand Up @@ -80,6 +80,7 @@ class ComposeContainer:
Health: Optional[str] = None
ExitCode: Optional[int] = None
Publishers: list[PublishedPortModel] = field(default_factory=list)
_docker_compose: Optional["DockerCompose"] = field(default=None, init=False, repr=False)

def __post_init__(self) -> None:
if self.Publishers:
Expand Down Expand Up @@ -116,6 +117,41 @@ 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")

# WaitStrategy compatibility methods
def get_container_host_ip(self) -> str:
"""Get the host IP for the container."""
# Simplified implementation - wait strategies don't use this yet
return "127.0.0.1"

def get_exposed_port(self, port: int) -> int:
"""Get the exposed port mapping for the given internal port."""
# Simplified implementation - wait strategies don't use this yet
return port

def get_logs(self) -> tuple[bytes, bytes]:
"""Get container logs."""
if not self._docker_compose:
raise RuntimeError("DockerCompose reference not set on ComposeContainer")
if not self.Service:
raise RuntimeError("Service name not set on ComposeContainer")
stdout, stderr = self._docker_compose.get_logs(self.Service)
return stdout.encode(), stderr.encode()

def get_wrapped_container(self) -> "ComposeContainer":
"""Get the underlying container object for compatibility."""
return self

def reload(self) -> None:
"""Reload container information for compatibility with wait strategies."""
# ComposeContainer doesn't need explicit reloading as it's fetched fresh
# each time through get_container(), but we need this method for compatibility
pass

@property
def status(self) -> str:
"""Get container status for compatibility with wait strategies."""
return self.State or "unknown"


@dataclass
class DockerCompose:
Expand Down Expand Up @@ -178,6 +214,7 @@ class DockerCompose:
services: Optional[list[str]] = None
docker_command_path: Optional[str] = None
profiles: Optional[list[str]] = None
_wait_strategies: Optional[dict[str, Any]] = field(default=None, init=False, repr=False)

def __post_init__(self) -> None:
if isinstance(self.compose_file_name, str):
Expand Down Expand Up @@ -213,6 +250,15 @@ def compose_command_property(self) -> list[str]:
docker_compose_cmd += ["--env-file", self.env_file]
return docker_compose_cmd

def waiting_for(self, strategies: dict[str, WaitStrategy]) -> "DockerCompose":
"""
Set wait strategies for specific services.
Args:
strategies: Dictionary mapping service names to wait strategies
"""
self._wait_strategies = strategies
return self

def start(self) -> None:
"""
Starts the docker compose environment.
Expand Down Expand Up @@ -241,6 +287,11 @@ def start(self) -> None:

self._run_command(cmd=up_cmd)

if self._wait_strategies:
for service, strategy in self._wait_strategies.items():
container = self.get_container(service_name=service)
strategy.wait_until_ready(container)

def stop(self, down: bool = True) -> None:
"""
Stops the docker compose environment.
Expand Down Expand Up @@ -317,7 +368,7 @@ def get_containers(self, include_all: bool = False) -> list[ComposeContainer]:
result = self._run_command(cmd=cmd)
stdout = split(r"\r?\n", result.stdout.decode("utf-8"))

containers = []
containers: list[ComposeContainer] = []
# one line per service in docker 25, single array for docker 24.0.2
for line in stdout:
if not line:
Expand All @@ -328,6 +379,10 @@ def get_containers(self, include_all: bool = False) -> list[ComposeContainer]:
else:
containers.append(_ignore_properties(ComposeContainer, data))

# Set the docker_compose reference on each container
for container in containers:
container._docker_compose = self

return containers

def get_container(
Expand All @@ -352,6 +407,7 @@ def get_container(
if not matching_containers:
raise ContainerIsNotRunning(f"{service_name} is not running in the compose context")

matching_containers[0]._docker_compose = self
return matching_containers[0]

def exec_in_container(
Expand Down Expand Up @@ -388,12 +444,18 @@ def _run_command(
context: Optional[str] = None,
) -> CompletedProcess[bytes]:
context = context or str(self.context)
return subprocess_run(
cmd,
capture_output=True,
check=True,
cwd=context,
)
try:
return subprocess_run(
cmd,
capture_output=True,
check=True,
cwd=context,
)
except CalledProcessError as e:
logger.error(f"Command '{e.cmd}' failed with exit code {e.returncode}")
logger.error(f"STDOUT:\n{e.stdout.decode(errors='ignore')}")
logger.error(f"STDERR:\n{e.stderr.decode(errors='ignore')}")
raise e from e

def get_service_port(
self,
Expand Down Expand Up @@ -452,16 +514,54 @@ def get_service_host_and_port(
publisher = self.get_container(service_name).get_publisher(by_port=port).normalize()
return publisher.URL, publisher.PublishedPort

@wait_container_is_ready(HTTPError, URLError)
def wait_for(self, url: str) -> "DockerCompose":
"""
Waits for a response from a given URL. This is typically used to block until a service in
the environment has started and is responding. Note that it does not assert any sort of
return code, only check that the connection was successful.

This is a convenience method that internally uses HttpWaitStrategy. For more complex
wait scenarios, consider using the structured wait strategies with `waiting_for()`.

Args:
url: URL from one of the services in the environment to use to wait on.

Example:
# Simple URL wait (legacy style)
compose.wait_for("http://localhost:8080") \
\
# For more complex scenarios, use structured wait strategies:
from testcontainers.core.waiting_utils import HttpWaitStrategy, LogMessageWaitStrategy \
\
compose.waiting_for({ \
"web": HttpWaitStrategy(8080).for_status_code(200), \
"db": LogMessageWaitStrategy("database system is ready to accept connections") \
})
"""
import time
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen

# For simple URL waiting when we have multiple containers,
# we'll do a direct HTTP check instead of using the container-based strategy
start_time = time.time()
timeout = 120 # Default timeout

while True:
if time.time() - start_time > timeout:
raise TimeoutError(f"URL {url} not ready within {timeout} seconds")

try:
request = Request(url, method="GET")
with urlopen(request, timeout=1) as response:
if 200 <= response.status < 400:
return self
except (URLError, HTTPError, ConnectionResetError, ConnectionRefusedError, BrokenPipeError, OSError):
# Any connection error means we should keep waiting
pass

time.sleep(1)

with urlopen(url) as response:
response.read()
return self
27 changes: 25 additions & 2 deletions core/testcontainers/core/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
from testcontainers.core.labels import LABEL_SESSION_ID, SESSION_ID
from testcontainers.core.network import Network
from testcontainers.core.utils import is_arm, setup_logger
from testcontainers.core.waiting_utils import wait_container_is_ready, wait_for_logs
from testcontainers.core.wait_strategies import LogMessageWaitStrategy
from testcontainers.core.waiting_utils import WaitStrategy, wait_container_is_ready

if TYPE_CHECKING:
from docker.models.containers import Container
Expand Down Expand Up @@ -69,6 +70,7 @@ def __init__(
volumes: Optional[list[tuple[str, str, str]]] = None,
network: Optional[Network] = None,
network_aliases: Optional[list[str]] = None,
_wait_strategy: Optional[WaitStrategy] = None,
**kwargs: Any,
) -> None:
self.env = env or {}
Expand Down Expand Up @@ -96,6 +98,7 @@ def __init__(
self.with_network_aliases(*network_aliases)

self._kwargs = kwargs
self._wait_strategy: Optional[WaitStrategy] = _wait_strategy

def with_env(self, key: str, value: str) -> Self:
self.env[key] = value
Expand Down Expand Up @@ -165,6 +168,11 @@ def maybe_emulate_amd64(self) -> Self:
return self.with_kwargs(platform="linux/amd64")
return self

def waiting_for(self, strategy: WaitStrategy) -> "DockerContainer":
"""Set a wait strategy to be used after container start."""
self._wait_strategy = strategy
return self

def start(self) -> Self:
if not c.ryuk_disabled and self.image != c.ryuk_image:
logger.debug("Creating Ryuk container")
Expand Down Expand Up @@ -195,6 +203,9 @@ def start(self) -> Self:
**{**network_kwargs, **self._kwargs},
)

if self._wait_strategy is not None:
self._wait_strategy.wait_until_ready(self)

logger.info("Container started: %s", self._container.short_id)
return self

Expand Down Expand Up @@ -264,6 +275,18 @@ 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 reload(self) -> None:
"""Reload container information for compatibility with wait strategies."""
if self._container:
self._container.reload()

@property
def status(self) -> str:
"""Get container status for compatibility with wait strategies."""
if not self._container:
return "not_started"
return cast("str", self._container.status)

def exec(self, command: Union[str, list[str]]) -> ExecResult:
if not self._container:
raise ContainerStartException("Container should be started before executing a command")
Expand Down Expand Up @@ -319,7 +342,7 @@ def _create_instance(cls) -> "Reaper":
)
rc = Reaper._container
assert rc is not None
wait_for_logs(rc, r".* Started!", timeout=20, raise_on_exit=True)
rc.waiting_for(LogMessageWaitStrategy(r".* Started!").with_startup_timeout(20))

container_host = rc.get_container_host_ip()
container_port = int(rc.get_exposed_port(8080))
Expand Down
1 change: 1 addition & 0 deletions core/testcontainers/core/generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ def _create_connection_url(
if self._container is None:
raise ContainerStartException("container has not been started")
host = host or self.get_container_host_ip()
assert port is not None
port = self.get_exposed_port(port)
quoted_password = quote(password, safe=" +")
url = f"{dialect}://{username}:{quoted_password}@{host}:{port}"
Expand Down
Loading