5
5
from os import PathLike
6
6
from platform import system
7
7
from re import split
8
- from subprocess import CompletedProcess
8
+ from subprocess import CalledProcessError , CompletedProcess
9
9
from subprocess import run as subprocess_run
10
10
from typing import Any , Callable , Literal , Optional , TypeVar , Union , cast
11
- from urllib .error import HTTPError , URLError
12
- from urllib .request import urlopen
13
11
14
12
from testcontainers .core .exceptions import ContainerIsNotRunning , NoSuchPortExposed
15
- from testcontainers .core .waiting_utils import wait_container_is_ready
13
+ from testcontainers .core .utils import setup_logger
14
+ from testcontainers .core .waiting_utils import WaitStrategy
16
15
17
16
_IPT = TypeVar ("_IPT" )
18
17
_WARNINGS = {"DOCKER_COMPOSE_GET_CONFIG" : "get_config is experimental, see testcontainers/testcontainers-python#669" }
19
18
19
+ logger = setup_logger (__name__ )
20
+
20
21
21
22
def _ignore_properties (cls : type [_IPT ], dict_ : any ) -> _IPT :
22
23
"""omits extra fields like @JsonIgnoreProperties(ignoreUnknown = true)
@@ -77,6 +78,7 @@ class ComposeContainer:
77
78
Health : Optional [str ] = None
78
79
ExitCode : Optional [str ] = None
79
80
Publishers : list [PublishedPort ] = field (default_factory = list )
81
+ _docker_compose : Optional ["DockerCompose" ] = field (default = None , init = False , repr = False )
80
82
81
83
def __post_init__ (self ):
82
84
if self .Publishers :
@@ -112,6 +114,41 @@ def get_publisher(
112
114
def _matches_protocol (prefer_ip_version , r ):
113
115
return (":" in r .URL ) is (prefer_ip_version == "IPv6" )
114
116
117
+ # WaitStrategy compatibility methods
118
+ def get_container_host_ip (self ) -> str :
119
+ """Get the host IP for the container."""
120
+ # Simplified implementation - wait strategies don't use this yet
121
+ return "127.0.0.1"
122
+
123
+ def get_exposed_port (self , port : int ) -> int :
124
+ """Get the exposed port mapping for the given internal port."""
125
+ # Simplified implementation - wait strategies don't use this yet
126
+ return port
127
+
128
+ def get_logs (self ) -> tuple [bytes , bytes ]:
129
+ """Get container logs."""
130
+ if not self ._docker_compose :
131
+ raise RuntimeError ("DockerCompose reference not set on ComposeContainer" )
132
+ if not self .Service :
133
+ raise RuntimeError ("Service name not set on ComposeContainer" )
134
+ stdout , stderr = self ._docker_compose .get_logs (self .Service )
135
+ return stdout .encode (), stderr .encode ()
136
+
137
+ def get_wrapped_container (self ) -> "ComposeContainer" :
138
+ """Get the underlying container object for compatibility."""
139
+ return self
140
+
141
+ def reload (self ) -> None :
142
+ """Reload container information for compatibility with wait strategies."""
143
+ # ComposeContainer doesn't need explicit reloading as it's fetched fresh
144
+ # each time through get_container(), but we need this method for compatibility
145
+ pass
146
+
147
+ @property
148
+ def status (self ) -> str :
149
+ """Get container status for compatibility with wait strategies."""
150
+ return self .State or "unknown"
151
+
115
152
116
153
@dataclass
117
154
class DockerCompose :
@@ -174,6 +211,7 @@ class DockerCompose:
174
211
services : Optional [list [str ]] = None
175
212
docker_command_path : Optional [str ] = None
176
213
profiles : Optional [list [str ]] = None
214
+ _wait_strategies : Optional [dict [str , Any ]] = field (default = None , init = False , repr = False )
177
215
178
216
def __post_init__ (self ):
179
217
if isinstance (self .compose_file_name , str ):
@@ -207,6 +245,16 @@ def compose_command_property(self) -> list[str]:
207
245
docker_compose_cmd += ["--env-file" , self .env_file ]
208
246
return docker_compose_cmd
209
247
248
+ def waiting_for (self , strategies : dict [str , WaitStrategy ]) -> "DockerCompose" :
249
+ """
250
+ Set wait strategies for specific services.
251
+
252
+ Args:
253
+ strategies: Dictionary mapping service names to wait strategies
254
+ """
255
+ self ._wait_strategies = strategies
256
+ return self
257
+
210
258
def start (self ) -> None :
211
259
"""
212
260
Starts the docker compose environment.
@@ -235,6 +283,11 @@ def start(self) -> None:
235
283
236
284
self ._run_command (cmd = up_cmd )
237
285
286
+ if self ._wait_strategies :
287
+ for service , strategy in self ._wait_strategies .items ():
288
+ container = self .get_container (service_name = service )
289
+ strategy .wait_until_ready (container )
290
+
238
291
def stop (self , down = True ) -> None :
239
292
"""
240
293
Stops the docker compose environment.
@@ -322,6 +375,10 @@ def get_containers(self, include_all=False) -> list[ComposeContainer]:
322
375
else :
323
376
containers .append (_ignore_properties (ComposeContainer , data ))
324
377
378
+ # Set the docker_compose reference on each container
379
+ for container in containers :
380
+ container ._docker_compose = self
381
+
325
382
return containers
326
383
327
384
def get_container (
@@ -369,7 +426,13 @@ def exec_in_container(
369
426
exit_code: The command's exit code.
370
427
"""
371
428
if not service_name :
372
- service_name = self .get_container ().Service
429
+ containers = self .get_containers ()
430
+ if len (containers ) != 1 :
431
+ raise ContainerIsNotRunning (
432
+ f"exec_in_container failed because no service_name given "
433
+ f"and there is not exactly 1 container (but { len (containers )} )"
434
+ )
435
+ service_name = containers [0 ].Service
373
436
exec_cmd = [* self .compose_command_property , "exec" , "-T" , service_name , * command ]
374
437
result = self ._run_command (cmd = exec_cmd )
375
438
@@ -381,12 +444,18 @@ def _run_command(
381
444
context : Optional [str ] = None ,
382
445
) -> CompletedProcess [bytes ]:
383
446
context = context or self .context
384
- return subprocess_run (
385
- cmd ,
386
- capture_output = True ,
387
- check = True ,
388
- cwd = context ,
389
- )
447
+ try :
448
+ return subprocess_run (
449
+ cmd ,
450
+ capture_output = True ,
451
+ check = True ,
452
+ cwd = context ,
453
+ )
454
+ except CalledProcessError as e :
455
+ logger .error (f"Command '{ e .cmd } ' failed with exit code { e .returncode } " )
456
+ logger .error (f"STDOUT:\n { e .stdout .decode (errors = 'ignore' )} " )
457
+ logger .error (f"STDERR:\n { e .stderr .decode (errors = 'ignore' )} " )
458
+ raise e from e
390
459
391
460
def get_service_port (
392
461
self ,
@@ -440,16 +509,52 @@ def get_service_host_and_port(
440
509
publisher = self .get_container (service_name ).get_publisher (by_port = port ).normalize ()
441
510
return publisher .URL , publisher .PublishedPort
442
511
443
- @wait_container_is_ready (HTTPError , URLError )
444
512
def wait_for (self , url : str ) -> "DockerCompose" :
445
513
"""
446
514
Waits for a response from a given URL. This is typically used to block until a service in
447
515
the environment has started and is responding. Note that it does not assert any sort of
448
516
return code, only check that the connection was successful.
449
517
518
+ This is a convenience method that internally uses HttpWaitStrategy. For more complex
519
+ wait scenarios, consider using the structured wait strategies with `waiting_for()`.
520
+
450
521
Args:
451
522
url: URL from one of the services in the environment to use to wait on.
523
+
524
+ Example:
525
+ # Simple URL wait (legacy style)
526
+ compose.wait_for("http://localhost:8080")
527
+
528
+ # For more complex scenarios, use structured wait strategies:
529
+ from testcontainers.core.waiting_utils import HttpWaitStrategy, LogMessageWaitStrategy
530
+
531
+ compose.waiting_for({
532
+ "web": HttpWaitStrategy(8080).for_status_code(200),
533
+ "db": LogMessageWaitStrategy("database system is ready to accept connections")
534
+ })
452
535
"""
453
- with urlopen (url ) as response :
454
- response .read ()
536
+ import time
537
+ from urllib .error import HTTPError , URLError
538
+ from urllib .request import Request , urlopen
539
+
540
+ # For simple URL waiting when we have multiple containers,
541
+ # we'll do a direct HTTP check instead of using the container-based strategy
542
+ start_time = time .time ()
543
+ timeout = 120 # Default timeout
544
+
545
+ while True :
546
+ if time .time () - start_time > timeout :
547
+ raise TimeoutError (f"URL { url } not ready within { timeout } seconds" )
548
+
549
+ try :
550
+ request = Request (url , method = "GET" )
551
+ with urlopen (request , timeout = 1 ) as response :
552
+ if 200 <= response .status < 400 :
553
+ return self
554
+ except (URLError , HTTPError , ConnectionResetError , ConnectionRefusedError , BrokenPipeError , OSError ):
555
+ # Any connection error means we should keep waiting
556
+ pass
557
+
558
+ time .sleep (1 )
559
+
455
560
return self
0 commit comments