Skip to content

Commit ad66715

Browse files
add status wait strategy
1 parent fe941b1 commit ad66715

File tree

4 files changed

+93
-10
lines changed

4 files changed

+93
-10
lines changed

core/testcontainers/core/container.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,8 +247,11 @@ def get_container_host_ip(self) -> str:
247247
# ensure that we covered all possible connection_modes
248248
assert_never(connection_mode)
249249

250-
@wait_container_is_ready()
251250
def get_exposed_port(self, port: int) -> int:
251+
from testcontainers.core.wait_strategies import ContainerStatusWaitStrategy as C
252+
253+
C().wait_until_ready(self)
254+
252255
if self.get_docker_client().get_connection_mode().use_mapped_port:
253256
c = self._container
254257
assert c is not None

core/testcontainers/core/docker_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ def get_container(self, container_id: str) -> dict[str, Any]:
174174
"""
175175
Get the container with a given identifier.
176176
"""
177-
containers = self.client.api.containers(filters={"id": container_id})
177+
containers = self.client.api.containers(all=True, filters={"id": container_id})
178178
if not containers:
179179
raise RuntimeError(f"Could not get container with id {container_id}")
180180
return cast("dict[str, Any]", containers[0])

core/testcontainers/core/wait_strategies.py

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,18 @@
3131
import time
3232
from datetime import timedelta
3333
from pathlib import Path
34-
from typing import Any, Callable, Optional, Union
34+
from typing import TYPE_CHECKING, Any, Callable, Optional, Union
3535
from urllib.error import HTTPError, URLError
3636
from urllib.request import Request, urlopen
3737

3838
from testcontainers.core.utils import setup_logger
39-
39+
from . import testcontainers_config
4040
# Import base classes from waiting_utils to make them available for tests
41-
from .waiting_utils import WaitStrategy, WaitStrategyTarget
41+
from testcontainers.core.waiting_utils import WaitStrategy, WaitStrategyTarget
42+
from testcontainers.compose import DockerCompose
43+
44+
if TYPE_CHECKING:
45+
from testcontainers.core.container import DockerContainer
4246

4347
logger = setup_logger(__name__)
4448

@@ -718,6 +722,60 @@ def wait_until_ready(self, container: WaitStrategyTarget) -> None:
718722
time.sleep(self._poll_interval)
719723

720724

725+
class ContainerStatusWaitStrategy(WaitStrategy):
726+
"""
727+
The possible values for the container status are:
728+
created
729+
running
730+
paused
731+
restarting
732+
exited
733+
removing
734+
dead
735+
https://docs.docker.com/reference/cli/docker/container/ls/#status
736+
"""
737+
CONTINUE_STATUSES = {"created", "restarting"}
738+
739+
def __init__(self):
740+
super().__init__()
741+
742+
def wait_until_ready(self, container: WaitStrategyTarget) -> None:
743+
result = self._poll(lambda: self.running(self.get_status(container)))
744+
if not result:
745+
raise TimeoutError("container did not become running")
746+
747+
@staticmethod
748+
def running(status: str) -> bool:
749+
if status == "running":
750+
logger.debug("status is now running")
751+
return True
752+
if status in ContainerStatusWaitStrategy.CONTINUE_STATUSES:
753+
logger.debug("status is %s, which is valid for continuing (%s)", status, ContainerStatusWaitStrategy.CONTINUE_STATUSES)
754+
return False
755+
raise StopIteration(f"container status not valid for continuing: {status}")
756+
757+
def get_status(self, container: Any) -> str:
758+
from testcontainers.core.container import DockerContainer
759+
760+
if isinstance(container, DockerContainer):
761+
return self._get_status_tc_container(container)
762+
if isinstance(container, DockerCompose):
763+
return self._get_status_compose_container(container)
764+
raise TypeError(f"not supported operation: 'get_status' for type: {type(container)}")
765+
766+
@staticmethod
767+
def _get_status_tc_container(container: "DockerContainer") -> str:
768+
logger.debug("fetching status of container %s", container)
769+
wrapped = container.get_wrapped_container()
770+
wrapped.reload()
771+
return wrapped.status
772+
773+
@staticmethod
774+
def _get_status_compose_container(container: DockerCompose) -> str:
775+
logger.debug("fetching status of compose container %s", container)
776+
raise NotImplementedError
777+
778+
721779
class CompositeWaitStrategy(WaitStrategy):
722780
"""
723781
Wait for multiple conditions to be satisfied in sequence.
@@ -816,6 +874,7 @@ def wait_until_ready(self, container: WaitStrategyTarget) -> None:
816874

817875
__all__ = [
818876
"CompositeWaitStrategy",
877+
"ContainerStatusWaitStrategy",
819878
"FileExistsWaitStrategy",
820879
"HealthcheckWaitStrategy",
821880
"HttpWaitStrategy",

core/testcontainers/core/waiting_utils.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
import wrapt
2323

24-
from testcontainers.core.config import testcontainers_config as config
24+
from testcontainers.core.config import testcontainers_config
2525
from testcontainers.core.utils import setup_logger
2626

2727
logger = setup_logger(__name__)
@@ -73,8 +73,8 @@ class WaitStrategy(ABC):
7373
"""Base class for all wait strategies."""
7474

7575
def __init__(self) -> None:
76-
self._startup_timeout: float = config.timeout
77-
self._poll_interval: float = config.sleep_time
76+
self._startup_timeout: float = testcontainers_config.timeout
77+
self._poll_interval: float = testcontainers_config.sleep_time
7878

7979
def with_startup_timeout(self, timeout: Union[int, timedelta]) -> "WaitStrategy":
8080
"""Set the maximum time to wait for the container to be ready."""
@@ -97,6 +97,27 @@ def wait_until_ready(self, container: WaitStrategyTarget) -> None:
9797
"""Wait until the container is ready."""
9898
pass
9999

100+
def _poll(self, check: Callable[[], bool]) -> bool:
101+
start = time.time()
102+
while True:
103+
start_attempt = time.time()
104+
duration = start_attempt - start
105+
if duration > self._startup_timeout:
106+
return False
107+
108+
# noinspection PyBroadException
109+
try:
110+
result = check()
111+
if result:
112+
return result
113+
except StopIteration:
114+
return False
115+
except: # noqa: E722, RUF100
116+
pass
117+
118+
seconds_left_until_next = self._poll_interval - (time.time() - start_attempt)
119+
time.sleep(max(0.0, seconds_left_until_next))
120+
100121

101122
# Keep existing wait_container_is_ready but make it use the new system internally
102123
def wait_container_is_ready(*transient_exceptions: type[Exception]) -> Callable[[F], F]:
@@ -194,7 +215,7 @@ def wait_for(condition: Callable[..., bool]) -> bool:
194215
def wait_for_logs(
195216
container: WaitStrategyTarget,
196217
predicate: Union[Callable[[str], bool], str, WaitStrategy],
197-
timeout: float = config.timeout,
218+
timeout: float = testcontainers_config.timeout,
198219
interval: float = 1,
199220
predicate_streams_and: bool = False,
200221
raise_on_exit: bool = False,
@@ -261,7 +282,7 @@ def wait_for_logs(
261282
# Original implementation for backwards compatibility
262283
re_predicate: Optional[Callable[[str], Any]] = None
263284
if timeout is None:
264-
timeout = config.timeout
285+
timeout = testcontainers_config.timeout
265286
if isinstance(predicate, str):
266287
re_predicate = re.compile(predicate, re.MULTILINE).search
267288
elif callable(predicate):

0 commit comments

Comments
 (0)