Skip to content

Commit eac2016

Browse files
authored
Merge branch 'main' into main
2 parents ceda00a + bb646e9 commit eac2016

28 files changed

+3750
-1321
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.1"
33
}

.github/workflows/ci-core.yml

Lines changed: 2 additions & 2 deletions
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
@@ -24,7 +24,7 @@ jobs:
2424
- name: Install Python dependencies
2525
run: poetry install --all-extras
2626
- name: Run twine check
27-
run: poetry build && poetry run twine check dist/*.tar.gz
27+
run: rm -f LICENSE.txt && poetry build && poetry run twine check dist/*.tar.gz
2828
- name: Set up Docker
2929
uses: docker/setup-docker-action@v4
3030
- name: Run tests

CHANGELOG.md

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

3+
## [4.13.1](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.13.0...testcontainers-v4.13.1) (2025-09-24)
4+
5+
6+
### Bug Fixes
7+
8+
* **core:** Added 'compose' in compose_command_property ([#879](https://github.com/testcontainers/testcontainers-python/issues/879)) ([769b7b6](https://github.com/testcontainers/testcontainers-python/commit/769b7b688fe254cb8e38e05f453c4e3fe7999ad5))
9+
* **core:** make sure context manager exits ([#876](https://github.com/testcontainers/testcontainers-python/issues/876)) ([10089f6](https://github.com/testcontainers/testcontainers-python/commit/10089f6e2fe07e53cc47a69521e67a1bf3310065))
10+
11+
## [4.13.0](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.12.0...testcontainers-v4.13.0) (2025-08-27)
12+
13+
14+
### Features
15+
16+
* **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))
17+
* **core:** add enhanced wait strategies ([#855](https://github.com/testcontainers/testcontainers-python/issues/855)) ([60d21f8](https://github.com/testcontainers/testcontainers-python/commit/60d21f875f49f52e170b0714e8790080a6cb4c71))
18+
* **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))
19+
20+
21+
### Bug Fixes
22+
23+
* 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))
24+
* **core:** improper reading of .testcontainers.properties ([#863](https://github.com/testcontainers/testcontainers-python/issues/863)) ([350f246](https://github.com/testcontainers/testcontainers-python/commit/350f246a3b6367d727046b8967a63d1c055cf324))
25+
* **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))
26+
327
## [4.12.0](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.11.0...testcontainers-v4.12.0) (2025-07-21)
428

529

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: 131 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,24 @@
1+
import sys
12
from dataclasses import asdict, dataclass, field, fields, is_dataclass
23
from functools import cached_property
34
from json import loads
4-
from logging import warning
5+
from logging import getLogger, warning
56
from os import PathLike
67
from platform import system
78
from re import split
8-
from subprocess import CompletedProcess
9+
from subprocess import CalledProcessError, CompletedProcess
910
from subprocess import run as subprocess_run
1011
from types import TracebackType
1112
from typing import Any, Callable, Literal, Optional, TypeVar, Union, cast
12-
from urllib.error import HTTPError, URLError
13-
from urllib.request import urlopen
1413

1514
from testcontainers.core.exceptions import ContainerIsNotRunning, NoSuchPortExposed
16-
from testcontainers.core.waiting_utils import wait_container_is_ready
15+
from testcontainers.core.waiting_utils import WaitStrategy
1716

1817
_IPT = TypeVar("_IPT")
1918
_WARNINGS = {"DOCKER_COMPOSE_GET_CONFIG": "get_config is experimental, see testcontainers/testcontainers-python#669"}
2019

20+
logger = getLogger(__name__)
21+
2122

2223
def _ignore_properties(cls: type[_IPT], dict_: Any) -> _IPT:
2324
"""omits extra fields like @JsonIgnoreProperties(ignoreUnknown = true)
@@ -35,8 +36,7 @@ def _ignore_properties(cls: type[_IPT], dict_: Any) -> _IPT:
3536
@dataclass
3637
class PublishedPortModel:
3738
"""
38-
Class that represents the response we get from compose when inquiring status
39-
via `DockerCompose.get_running_containers()`.
39+
Class that represents the response we get from compose when inquiring status via `DockerCompose.get_running_containers()`.
4040
"""
4141

4242
URL: Optional[str] = None
@@ -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,18 +210,25 @@ 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":
187-
self.start()
188-
return self
226+
try:
227+
self.start()
228+
return self
229+
except: # noqa: E722, RUF100
230+
self.__exit__(*sys.exc_info())
231+
raise
189232

190233
def __exit__(
191234
self, exc_type: Optional[type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType]
@@ -203,16 +246,29 @@ def docker_compose_command(self) -> list[str]:
203246

204247
@cached_property
205248
def compose_command_property(self) -> list[str]:
206-
docker_compose_cmd = [self.docker_command_path] if self.docker_command_path else ["docker", "compose"]
249+
docker_compose_cmd = (
250+
[self.docker_command_path, "compose"] if self.docker_command_path else ["docker", "compose"]
251+
)
207252
if self.compose_file_name:
208253
for file in self.compose_file_name:
209254
docker_compose_cmd += ["-f", file]
210255
if self.profiles:
211256
docker_compose_cmd += [item for profile in self.profiles for item in ["--profile", profile]]
212257
if self.env_file:
213-
docker_compose_cmd += ["--env-file", self.env_file]
258+
for env_file in self.env_file:
259+
docker_compose_cmd += ["--env-file", env_file]
214260
return docker_compose_cmd
215261

262+
def waiting_for(self, strategies: dict[str, WaitStrategy]) -> "DockerCompose":
263+
"""
264+
Set wait strategies for specific services.
265+
266+
Args:
267+
strategies: Dictionary mapping service names to wait strategies
268+
"""
269+
self._wait_strategies = strategies
270+
return self
271+
216272
def start(self) -> None:
217273
"""
218274
Starts the docker compose environment.
@@ -241,6 +297,11 @@ def start(self) -> None:
241297

242298
self._run_command(cmd=up_cmd)
243299

300+
if self._wait_strategies:
301+
for service, strategy in self._wait_strategies.items():
302+
container = self.get_container(service_name=service)
303+
strategy.wait_until_ready(container)
304+
244305
def stop(self, down: bool = True) -> None:
245306
"""
246307
Stops the docker compose environment.
@@ -317,7 +378,7 @@ def get_containers(self, include_all: bool = False) -> list[ComposeContainer]:
317378
result = self._run_command(cmd=cmd)
318379
stdout = split(r"\r?\n", result.stdout.decode("utf-8"))
319380

320-
containers = []
381+
containers: list[ComposeContainer] = []
321382
# one line per service in docker 25, single array for docker 24.0.2
322383
for line in stdout:
323384
if not line:
@@ -328,6 +389,10 @@ def get_containers(self, include_all: bool = False) -> list[ComposeContainer]:
328389
else:
329390
containers.append(_ignore_properties(ComposeContainer, data))
330391

392+
# Set the docker_compose reference on each container
393+
for container in containers:
394+
container._docker_compose = self
395+
331396
return containers
332397

333398
def get_container(
@@ -352,6 +417,7 @@ def get_container(
352417
if not matching_containers:
353418
raise ContainerIsNotRunning(f"{service_name} is not running in the compose context")
354419

420+
matching_containers[0]._docker_compose = self
355421
return matching_containers[0]
356422

357423
def exec_in_container(
@@ -388,12 +454,18 @@ def _run_command(
388454
context: Optional[str] = None,
389455
) -> CompletedProcess[bytes]:
390456
context = context or str(self.context)
391-
return subprocess_run(
392-
cmd,
393-
capture_output=True,
394-
check=True,
395-
cwd=context,
396-
)
457+
try:
458+
return subprocess_run(
459+
cmd,
460+
capture_output=True,
461+
check=True,
462+
cwd=context,
463+
)
464+
except CalledProcessError as e:
465+
logger.error(f"Command '{e.cmd}' failed with exit code {e.returncode}")
466+
logger.error(f"STDOUT:\n{e.stdout.decode(errors='ignore')}")
467+
logger.error(f"STDERR:\n{e.stderr.decode(errors='ignore')}")
468+
raise e from e
397469

398470
def get_service_port(
399471
self,
@@ -452,16 +524,54 @@ def get_service_host_and_port(
452524
publisher = self.get_container(service_name).get_publisher(by_port=port).normalize()
453525
return publisher.URL, publisher.PublishedPort
454526

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

0 commit comments

Comments
 (0)