Skip to content

Commit 3050fee

Browse files
Merge remote-tracking branch 'testcontainers/main' into reusable_containers
2 parents 6796a9a + aa47435 commit 3050fee

24 files changed

+3682
-1279
lines changed
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
".": "4.12.0"
2+
".": "4.13.0"
33
}

.github/workflows/ci-core.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jobs:
1414
strategy:
1515
fail-fast: false
1616
matrix:
17-
python-version: ["3.9", "3.10", "3.11", "3.12"]
17+
python-version: ["3.9", "3.11", "3.12", "3.13"]
1818
steps:
1919
- uses: actions/checkout@v4
2020
- name: Set up Python

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
# Changelog
22

3+
## [4.13.0](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.12.0...testcontainers-v4.13.0) (2025-08-27)
4+
5+
6+
### Features
7+
8+
* **azurite:** Enhance connection string generation for network and local access ([#859](https://github.com/testcontainers/testcontainers-python/issues/859)) ([b21e5e3](https://github.com/testcontainers/testcontainers-python/commit/b21e5e38075ddbd71fb4f97e843abc104dec6beb))
9+
* **core:** add enhanced wait strategies ([#855](https://github.com/testcontainers/testcontainers-python/issues/855)) ([60d21f8](https://github.com/testcontainers/testcontainers-python/commit/60d21f875f49f52e170b0714e8790080a6cb4c71))
10+
* **core:** DockerCompose: support list of env_files ([#847](https://github.com/testcontainers/testcontainers-python/issues/847)) ([fe206eb](https://github.com/testcontainers/testcontainers-python/commit/fe206eb48ee9e18623761926900bfc33a8a869a7))
11+
12+
13+
### Bug Fixes
14+
15+
* assert-in-get_container_host_ip-before-start ([#862](https://github.com/testcontainers/testcontainers-python/issues/862)) ([fc4155e](https://github.com/testcontainers/testcontainers-python/commit/fc4155eb70509ba236fff771c2f8973667acb098))
16+
* **core:** improper reading of .testcontainers.properties ([#863](https://github.com/testcontainers/testcontainers-python/issues/863)) ([350f246](https://github.com/testcontainers/testcontainers-python/commit/350f246a3b6367d727046b8967a63d1c055cf324))
17+
* **core:** Make TC_POOLING_INTERVAL/sleep_time a float ([#839](https://github.com/testcontainers/testcontainers-python/issues/839)) ([a072f3f](https://github.com/testcontainers/testcontainers-python/commit/a072f3fad46b3b3e7c5bea6255f27b79826aaf5f))
18+
319
## [4.12.0](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.11.0...testcontainers-v4.12.0) (2025-07-21)
420

521

conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@
161161

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

core/README.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ Testcontainers Core
1414

1515
.. autoclass:: testcontainers.core.generic.DbContainer
1616

17+
.. autoclass:: testcontainers.core.wait_strategies.WaitStrategy
18+
1719
.. raw:: html
1820

1921
<hr>

core/testcontainers/compose/compose.py

Lines changed: 119 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,23 @@
11
from dataclasses import asdict, dataclass, field, fields, is_dataclass
22
from functools import cached_property
33
from json import loads
4-
from logging import warning
4+
from logging import getLogger, warning
55
from os import PathLike
66
from platform import system
77
from re import split
8-
from subprocess import CompletedProcess
8+
from subprocess import CalledProcessError, CompletedProcess
99
from subprocess import run as subprocess_run
1010
from types import TracebackType
1111
from typing import Any, Callable, Literal, Optional, TypeVar, Union, cast
12-
from urllib.error import HTTPError, URLError
13-
from urllib.request import urlopen
1412

1513
from testcontainers.core.exceptions import ContainerIsNotRunning, NoSuchPortExposed
16-
from testcontainers.core.waiting_utils import wait_container_is_ready
14+
from testcontainers.core.waiting_utils import WaitStrategy
1715

1816
_IPT = TypeVar("_IPT")
1917
_WARNINGS = {"DOCKER_COMPOSE_GET_CONFIG": "get_config is experimental, see testcontainers/testcontainers-python#669"}
2018

19+
logger = getLogger(__name__)
20+
2121

2222
def _ignore_properties(cls: type[_IPT], dict_: Any) -> _IPT:
2323
"""omits extra fields like @JsonIgnoreProperties(ignoreUnknown = true)
@@ -80,6 +80,7 @@ class ComposeContainer:
8080
Health: Optional[str] = None
8181
ExitCode: Optional[int] = None
8282
Publishers: list[PublishedPortModel] = field(default_factory=list)
83+
_docker_compose: Optional["DockerCompose"] = field(default=None, init=False, repr=False)
8384

8485
def __post_init__(self) -> None:
8586
if self.Publishers:
@@ -116,6 +117,41 @@ def _matches_protocol(prefer_ip_version: str, r: PublishedPortModel) -> bool:
116117
r_url = r.URL
117118
return (r_url is not None and ":" in r_url) is (prefer_ip_version == "IPv6")
118119

120+
# WaitStrategy compatibility methods
121+
def get_container_host_ip(self) -> str:
122+
"""Get the host IP for the container."""
123+
# Simplified implementation - wait strategies don't use this yet
124+
return "127.0.0.1"
125+
126+
def get_exposed_port(self, port: int) -> int:
127+
"""Get the exposed port mapping for the given internal port."""
128+
# Simplified implementation - wait strategies don't use this yet
129+
return port
130+
131+
def get_logs(self) -> tuple[bytes, bytes]:
132+
"""Get container logs."""
133+
if not self._docker_compose:
134+
raise RuntimeError("DockerCompose reference not set on ComposeContainer")
135+
if not self.Service:
136+
raise RuntimeError("Service name not set on ComposeContainer")
137+
stdout, stderr = self._docker_compose.get_logs(self.Service)
138+
return stdout.encode(), stderr.encode()
139+
140+
def get_wrapped_container(self) -> "ComposeContainer":
141+
"""Get the underlying container object for compatibility."""
142+
return self
143+
144+
def reload(self) -> None:
145+
"""Reload container information for compatibility with wait strategies."""
146+
# ComposeContainer doesn't need explicit reloading as it's fetched fresh
147+
# each time through get_container(), but we need this method for compatibility
148+
pass
149+
150+
@property
151+
def status(self) -> str:
152+
"""Get container status for compatibility with wait strategies."""
153+
return self.State or "unknown"
154+
119155

120156
@dataclass
121157
class DockerCompose:
@@ -137,7 +173,7 @@ class DockerCompose:
137173
Wait for the services to be healthy
138174
(as per healthcheck definitions in the docker compose configuration)
139175
env_file:
140-
Path to an '.env' file containing environment variables
176+
Path(s) to an '.env' file containing environment variables
141177
to pass to docker compose.
142178
services:
143179
The list of services to use from this DockerCompose.
@@ -174,14 +210,17 @@ class DockerCompose:
174210
build: bool = False
175211
wait: bool = True
176212
keep_volumes: bool = False
177-
env_file: Optional[str] = None
213+
env_file: Optional[Union[str, list[str]]] = None
178214
services: Optional[list[str]] = None
179215
docker_command_path: Optional[str] = None
180216
profiles: Optional[list[str]] = None
217+
_wait_strategies: Optional[dict[str, Any]] = field(default=None, init=False, repr=False)
181218

182219
def __post_init__(self) -> None:
183220
if isinstance(self.compose_file_name, str):
184221
self.compose_file_name = [self.compose_file_name]
222+
if isinstance(self.env_file, str):
223+
self.env_file = [self.env_file]
185224

186225
def __enter__(self) -> "DockerCompose":
187226
self.start()
@@ -210,9 +249,19 @@ def compose_command_property(self) -> list[str]:
210249
if self.profiles:
211250
docker_compose_cmd += [item for profile in self.profiles for item in ["--profile", profile]]
212251
if self.env_file:
213-
docker_compose_cmd += ["--env-file", self.env_file]
252+
for env_file in self.env_file:
253+
docker_compose_cmd += ["--env-file", env_file]
214254
return docker_compose_cmd
215255

256+
def waiting_for(self, strategies: dict[str, WaitStrategy]) -> "DockerCompose":
257+
"""
258+
Set wait strategies for specific services.
259+
Args:
260+
strategies: Dictionary mapping service names to wait strategies
261+
"""
262+
self._wait_strategies = strategies
263+
return self
264+
216265
def start(self) -> None:
217266
"""
218267
Starts the docker compose environment.
@@ -241,6 +290,11 @@ def start(self) -> None:
241290

242291
self._run_command(cmd=up_cmd)
243292

293+
if self._wait_strategies:
294+
for service, strategy in self._wait_strategies.items():
295+
container = self.get_container(service_name=service)
296+
strategy.wait_until_ready(container)
297+
244298
def stop(self, down: bool = True) -> None:
245299
"""
246300
Stops the docker compose environment.
@@ -317,7 +371,7 @@ def get_containers(self, include_all: bool = False) -> list[ComposeContainer]:
317371
result = self._run_command(cmd=cmd)
318372
stdout = split(r"\r?\n", result.stdout.decode("utf-8"))
319373

320-
containers = []
374+
containers: list[ComposeContainer] = []
321375
# one line per service in docker 25, single array for docker 24.0.2
322376
for line in stdout:
323377
if not line:
@@ -328,6 +382,10 @@ def get_containers(self, include_all: bool = False) -> list[ComposeContainer]:
328382
else:
329383
containers.append(_ignore_properties(ComposeContainer, data))
330384

385+
# Set the docker_compose reference on each container
386+
for container in containers:
387+
container._docker_compose = self
388+
331389
return containers
332390

333391
def get_container(
@@ -352,6 +410,7 @@ def get_container(
352410
if not matching_containers:
353411
raise ContainerIsNotRunning(f"{service_name} is not running in the compose context")
354412

413+
matching_containers[0]._docker_compose = self
355414
return matching_containers[0]
356415

357416
def exec_in_container(
@@ -388,12 +447,18 @@ def _run_command(
388447
context: Optional[str] = None,
389448
) -> CompletedProcess[bytes]:
390449
context = context or str(self.context)
391-
return subprocess_run(
392-
cmd,
393-
capture_output=True,
394-
check=True,
395-
cwd=context,
396-
)
450+
try:
451+
return subprocess_run(
452+
cmd,
453+
capture_output=True,
454+
check=True,
455+
cwd=context,
456+
)
457+
except CalledProcessError as e:
458+
logger.error(f"Command '{e.cmd}' failed with exit code {e.returncode}")
459+
logger.error(f"STDOUT:\n{e.stdout.decode(errors='ignore')}")
460+
logger.error(f"STDERR:\n{e.stderr.decode(errors='ignore')}")
461+
raise e from e
397462

398463
def get_service_port(
399464
self,
@@ -452,16 +517,54 @@ def get_service_host_and_port(
452517
publisher = self.get_container(service_name).get_publisher(by_port=port).normalize()
453518
return publisher.URL, publisher.PublishedPort
454519

455-
@wait_container_is_ready(HTTPError, URLError)
456520
def wait_for(self, url: str) -> "DockerCompose":
457521
"""
458522
Waits for a response from a given URL. This is typically used to block until a service in
459523
the environment has started and is responding. Note that it does not assert any sort of
460524
return code, only check that the connection was successful.
461525
526+
This is a convenience method that internally uses HttpWaitStrategy. For more complex
527+
wait scenarios, consider using the structured wait strategies with `waiting_for()`.
528+
462529
Args:
463530
url: URL from one of the services in the environment to use to wait on.
531+
532+
Example:
533+
# Simple URL wait (legacy style)
534+
compose.wait_for("http://localhost:8080") \
535+
\
536+
# For more complex scenarios, use structured wait strategies:
537+
from testcontainers.core.waiting_utils import HttpWaitStrategy, LogMessageWaitStrategy \
538+
\
539+
compose.waiting_for({ \
540+
"web": HttpWaitStrategy(8080).for_status_code(200), \
541+
"db": LogMessageWaitStrategy("database system is ready to accept connections") \
542+
})
464543
"""
544+
import time
545+
from urllib.error import HTTPError, URLError
546+
from urllib.request import Request, urlopen
547+
548+
# For simple URL waiting when we have multiple containers,
549+
# we'll do a direct HTTP check instead of using the container-based strategy
550+
start_time = time.time()
551+
timeout = 120 # Default timeout
552+
553+
while True:
554+
if time.time() - start_time > timeout:
555+
raise TimeoutError(f"URL {url} not ready within {timeout} seconds")
556+
557+
try:
558+
request = Request(url, method="GET")
559+
with urlopen(request, timeout=1) as response:
560+
if 200 <= response.status < 400:
561+
return self
562+
except (URLError, HTTPError, ConnectionResetError, ConnectionRefusedError, BrokenPipeError, OSError):
563+
# Any connection error means we should keep waiting
564+
pass
565+
566+
time.sleep(1)
567+
465568
with urlopen(url) as response:
466569
response.read()
467570
return self

core/testcontainers/core/config.py

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
import docker
1313

14+
ENABLE_FLAGS = ("yes", "true", "t", "y", "1")
15+
1416

1517
class ConnectionMode(Enum):
1618
bridge_ip = "bridge_ip"
@@ -45,16 +47,6 @@ def get_docker_socket() -> str:
4547
return "/var/run/docker.sock"
4648

4749

48-
def get_bool_env(name: str) -> bool:
49-
"""
50-
Get environment variable named `name` and convert it to bool.
51-
52-
Defaults to False.
53-
"""
54-
value = environ.get(name, "")
55-
return value.lower() in ("yes", "true", "t", "y", "1")
56-
57-
5850
TC_FILE = ".testcontainers.properties"
5951
TC_GLOBAL = Path.home() / TC_FILE
6052

@@ -99,11 +91,20 @@ def read_tc_properties() -> dict[str, str]:
9991

10092
@dataclass
10193
class TestcontainersConfiguration:
94+
def _render_bool(self, env_name: str, prop_name: str) -> bool:
95+
env_val = environ.get(env_name, None)
96+
if env_val is not None:
97+
return env_val.lower() in ENABLE_FLAGS
98+
prop_val = self.tc_properties.get(prop_name, None)
99+
if prop_val is not None:
100+
return prop_val.lower() in ENABLE_FLAGS
101+
return False
102+
102103
max_tries: int = int(environ.get("TC_MAX_TRIES", "120"))
103-
sleep_time: int = int(environ.get("TC_POOLING_INTERVAL", "1"))
104+
sleep_time: float = float(environ.get("TC_POOLING_INTERVAL", "1"))
104105
ryuk_image: str = environ.get("RYUK_CONTAINER_IMAGE", "testcontainers/ryuk:0.8.1")
105-
ryuk_privileged: bool = get_bool_env("TESTCONTAINERS_RYUK_PRIVILEGED")
106-
ryuk_disabled: bool = get_bool_env("TESTCONTAINERS_RYUK_DISABLED")
106+
_ryuk_privileged: Optional[bool] = None
107+
_ryuk_disabled: Optional[bool] = None
107108
_ryuk_docker_socket: str = ""
108109
ryuk_reconnection_timeout: str = environ.get("RYUK_RECONNECTION_TIMEOUT", "10s")
109110
tc_properties: dict[str, str] = field(default_factory=read_tc_properties)
@@ -134,17 +135,34 @@ def tc_properties_get_tc_host(self) -> Union[str, None]:
134135
warning(_WARNINGS.pop("tc_properties_get_tc_host"))
135136
return self.tc_properties.get("tc.host")
136137

137-
@property
138-
def tc_properties_tc_host(self) -> Union[str, None]:
139-
return self.tc_properties.get("tc.host")
140-
141-
@property
142138
def tc_properties_testcontainers_reuse_enable(self) -> bool:
143139
enabled = self.tc_properties.get("testcontainers.reuse.enable")
144140
return enabled == "true"
145141

146142
@property
147-
def timeout(self) -> int:
143+
def ryuk_privileged(self) -> bool:
144+
if self._ryuk_privileged is not None:
145+
return bool(self._ryuk_privileged)
146+
self._ryuk_privileged = self._render_bool("TESTCONTAINERS_RYUK_PRIVILEGED", "ryuk.container.privileged")
147+
return self._ryuk_privileged
148+
149+
@ryuk_privileged.setter
150+
def ryuk_privileged(self, value: bool) -> None:
151+
self._ryuk_privileged = value
152+
153+
@property
154+
def ryuk_disabled(self) -> bool:
155+
if self._ryuk_disabled is not None:
156+
return bool(self._ryuk_disabled)
157+
self._ryuk_disabled = self._render_bool("TESTCONTAINERS_RYUK_DISABLED", "ryuk.disabled")
158+
return self._ryuk_disabled
159+
160+
@ryuk_disabled.setter
161+
def ryuk_disabled(self, value: bool) -> None:
162+
self._ryuk_disabled = value
163+
164+
@property
165+
def timeout(self) -> float:
148166
return self.max_tries * self.sleep_time
149167

150168
@property

0 commit comments

Comments
 (0)