Skip to content

Commit f93f379

Browse files
fix(core): waiting improvements + remove decorators in core (#894)
1 parent fe941b1 commit f93f379

File tree

10 files changed

+155
-136
lines changed

10 files changed

+155
-136
lines changed

core/testcontainers/compose/compose.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,9 @@ def docker_compose_command(self) -> list[str]:
247247

248248
@cached_property
249249
def compose_command_property(self) -> list[str]:
250-
docker_compose_cmd = [self.docker_command_path, "compose"] if self.docker_command_path else ["docker", "compose"]
250+
docker_compose_cmd = (
251+
[self.docker_command_path, "compose"] if self.docker_command_path else ["docker", "compose"]
252+
)
251253
if self.compose_file_name:
252254
for file in self.compose_file_name:
253255
docker_compose_cmd += ["-f", file]

core/testcontainers/core/container.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from testcontainers.core.network import Network
2121
from testcontainers.core.utils import is_arm, setup_logger
2222
from testcontainers.core.wait_strategies import LogMessageWaitStrategy
23-
from testcontainers.core.waiting_utils import WaitStrategy, wait_container_is_ready
23+
from testcontainers.core.waiting_utils import WaitStrategy
2424

2525
if TYPE_CHECKING:
2626
from docker.models.containers import Container
@@ -247,8 +247,13 @@ 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+
return self._get_exposed_port(port)
255+
256+
def _get_exposed_port(self, port: int) -> int:
252257
if self.get_docker_client().get_connection_mode().use_mapped_port:
253258
c = self._container
254259
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/generic.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
from testcontainers.core.container import DockerContainer
1717
from testcontainers.core.exceptions import ContainerStartException
1818
from testcontainers.core.utils import raise_for_deprecated_parameter
19-
from testcontainers.core.waiting_utils import wait_container_is_ready
2019

2120
ADDITIONAL_TRANSIENT_ERRORS = []
2221
try:
@@ -34,8 +33,11 @@ class DbContainer(DockerContainer):
3433
Generic database container.
3534
"""
3635

37-
@wait_container_is_ready(*ADDITIONAL_TRANSIENT_ERRORS)
3836
def _connect(self) -> None:
37+
from testcontainers.core.wait_strategies import ContainerStatusWaitStrategy as C
38+
39+
C().with_transient_exceptions(*ADDITIONAL_TRANSIENT_ERRORS).wait_until_ready(self)
40+
3941
import sqlalchemy
4042

4143
engine = sqlalchemy.create_engine(self.get_connection_url())

core/testcontainers/core/wait_strategies.py

Lines changed: 81 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,20 @@
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, cast
3535
from urllib.error import HTTPError, URLError
3636
from urllib.request import Request, urlopen
3737

38+
from typing_extensions import Self
39+
40+
from testcontainers.compose import DockerCompose
3841
from testcontainers.core.utils import setup_logger
3942

4043
# Import base classes from waiting_utils to make them available for tests
41-
from .waiting_utils import WaitStrategy, WaitStrategyTarget
44+
from testcontainers.core.waiting_utils import WaitStrategy, WaitStrategyTarget
45+
46+
if TYPE_CHECKING:
47+
from testcontainers.core.container import DockerContainer
4248

4349
logger = setup_logger(__name__)
4450

@@ -77,22 +83,6 @@ def __init__(
7783
self._times = times
7884
self._predicate_streams_and = predicate_streams_and
7985

80-
def with_startup_timeout(self, timeout: Union[int, timedelta]) -> "LogMessageWaitStrategy":
81-
"""Set the maximum time to wait for the container to be ready."""
82-
if isinstance(timeout, timedelta):
83-
self._startup_timeout = int(timeout.total_seconds())
84-
else:
85-
self._startup_timeout = timeout
86-
return self
87-
88-
def with_poll_interval(self, interval: Union[float, timedelta]) -> "LogMessageWaitStrategy":
89-
"""Set how frequently to check if the container is ready."""
90-
if isinstance(interval, timedelta):
91-
self._poll_interval = interval.total_seconds()
92-
else:
93-
self._poll_interval = interval
94-
return self
95-
9686
def wait_until_ready(self, container: "WaitStrategyTarget") -> None:
9787
"""
9888
Wait until the specified message appears in the container logs.
@@ -198,22 +188,6 @@ def __init__(self, port: int, path: Optional[str] = "/") -> None:
198188
self._body: Optional[str] = None
199189
self._insecure_tls = False
200190

201-
def with_startup_timeout(self, timeout: Union[int, timedelta]) -> "HttpWaitStrategy":
202-
"""Set the maximum time to wait for the container to be ready."""
203-
if isinstance(timeout, timedelta):
204-
self._startup_timeout = int(timeout.total_seconds())
205-
else:
206-
self._startup_timeout = timeout
207-
return self
208-
209-
def with_poll_interval(self, interval: Union[float, timedelta]) -> "HttpWaitStrategy":
210-
"""Set how frequently to check if the container is ready."""
211-
if isinstance(interval, timedelta):
212-
self._poll_interval = interval.total_seconds()
213-
else:
214-
self._poll_interval = interval
215-
return self
216-
217191
@classmethod
218192
def from_url(cls, url: str) -> "HttpWaitStrategy":
219193
"""
@@ -483,22 +457,6 @@ class HealthcheckWaitStrategy(WaitStrategy):
483457
def __init__(self) -> None:
484458
super().__init__()
485459

486-
def with_startup_timeout(self, timeout: Union[int, timedelta]) -> "HealthcheckWaitStrategy":
487-
"""Set the maximum time to wait for the container to be ready."""
488-
if isinstance(timeout, timedelta):
489-
self._startup_timeout = int(timeout.total_seconds())
490-
else:
491-
self._startup_timeout = timeout
492-
return self
493-
494-
def with_poll_interval(self, interval: Union[float, timedelta]) -> "HealthcheckWaitStrategy":
495-
"""Set how frequently to check if the container is ready."""
496-
if isinstance(interval, timedelta):
497-
self._poll_interval = interval.total_seconds()
498-
else:
499-
self._poll_interval = interval
500-
return self
501-
502460
def wait_until_ready(self, container: WaitStrategyTarget) -> None:
503461
"""
504462
Wait until the container's health check reports as healthy.
@@ -581,22 +539,6 @@ def __init__(self, port: int) -> None:
581539
super().__init__()
582540
self._port = port
583541

584-
def with_startup_timeout(self, timeout: Union[int, timedelta]) -> "PortWaitStrategy":
585-
"""Set the maximum time to wait for the container to be ready."""
586-
if isinstance(timeout, timedelta):
587-
self._startup_timeout = int(timeout.total_seconds())
588-
else:
589-
self._startup_timeout = timeout
590-
return self
591-
592-
def with_poll_interval(self, interval: Union[float, timedelta]) -> "PortWaitStrategy":
593-
"""Set how frequently to check if the container is ready."""
594-
if isinstance(interval, timedelta):
595-
self._poll_interval = interval.total_seconds()
596-
else:
597-
self._poll_interval = interval
598-
return self
599-
600542
def wait_until_ready(self, container: WaitStrategyTarget) -> None:
601543
"""
602544
Wait until the specified port is available for connection.
@@ -654,22 +596,6 @@ def __init__(self, file_path: Union[str, Path]) -> None:
654596
super().__init__()
655597
self._file_path = Path(file_path)
656598

657-
def with_startup_timeout(self, timeout: Union[int, timedelta]) -> "FileExistsWaitStrategy":
658-
"""Set the maximum time to wait for the container to be ready."""
659-
if isinstance(timeout, timedelta):
660-
self._startup_timeout = int(timeout.total_seconds())
661-
else:
662-
self._startup_timeout = timeout
663-
return self
664-
665-
def with_poll_interval(self, interval: Union[float, timedelta]) -> "FileExistsWaitStrategy":
666-
"""Set how frequently to check if the container is ready."""
667-
if isinstance(interval, timedelta):
668-
self._poll_interval = interval.total_seconds()
669-
else:
670-
self._poll_interval = interval
671-
return self
672-
673599
def wait_until_ready(self, container: WaitStrategyTarget) -> None:
674600
"""
675601
Wait until the specified file exists on the host filesystem.
@@ -718,6 +644,65 @@ def wait_until_ready(self, container: WaitStrategyTarget) -> None:
718644
time.sleep(self._poll_interval)
719645

720646

647+
class ContainerStatusWaitStrategy(WaitStrategy):
648+
"""
649+
The possible values for the container status are:
650+
created
651+
running
652+
paused
653+
restarting
654+
exited
655+
removing
656+
dead
657+
https://docs.docker.com/reference/cli/docker/container/ls/#status
658+
"""
659+
660+
CONTINUE_STATUSES = frozenset(("created", "restarting"))
661+
662+
def __init__(self) -> None:
663+
super().__init__()
664+
665+
def wait_until_ready(self, container: WaitStrategyTarget) -> None:
666+
result = self._poll(lambda: self.running(self.get_status(container)))
667+
if not result:
668+
raise TimeoutError("container did not become running")
669+
670+
@staticmethod
671+
def running(status: str) -> bool:
672+
if status == "running":
673+
logger.debug("status is now running")
674+
return True
675+
if status in ContainerStatusWaitStrategy.CONTINUE_STATUSES:
676+
logger.debug(
677+
"status is %s, which is valid for continuing (%s)",
678+
status,
679+
ContainerStatusWaitStrategy.CONTINUE_STATUSES,
680+
)
681+
return False
682+
raise StopIteration(f"container status not valid for continuing: {status}")
683+
684+
def get_status(self, container: Any) -> str:
685+
from testcontainers.core.container import DockerContainer
686+
687+
if isinstance(container, DockerContainer):
688+
return self._get_status_tc_container(container)
689+
if isinstance(container, DockerCompose):
690+
return self._get_status_compose_container(container)
691+
raise TypeError(f"not supported operation: 'get_status' for type: {type(container)}")
692+
693+
@staticmethod
694+
def _get_status_tc_container(container: "DockerContainer") -> str:
695+
logger.debug("fetching status of container %s", container)
696+
wrapped = container.get_wrapped_container()
697+
wrapped.reload()
698+
return cast("str", wrapped.status)
699+
700+
@staticmethod
701+
def _get_status_compose_container(container: DockerCompose) -> str:
702+
logger.debug("fetching status of compose container %s", container)
703+
raise NotImplementedError
704+
705+
721706
class CompositeWaitStrategy(WaitStrategy):
722707
"""
723708
Wait for multiple conditions to be satisfied in sequence.
@@ -748,42 +733,22 @@ def __init__(self, *strategies: WaitStrategy) -> None:
748733
super().__init__()
749734
self._strategies = list(strategies)
750735

751-
def with_startup_timeout(self, timeout: Union[int, timedelta]) -> "CompositeWaitStrategy":
752-
"""
753-
Set the startup timeout for all contained strategies.
754-
755-
Args:
756-
timeout: Maximum time to wait in seconds
757-
758-
Returns:
759-
self for method chaining
760-
"""
761-
if isinstance(timeout, timedelta):
762-
self._startup_timeout = int(timeout.total_seconds())
763-
else:
764-
self._startup_timeout = timeout
765-
766-
for strategy in self._strategies:
767-
strategy.with_startup_timeout(timeout)
736+
def with_poll_interval(self, interval: Union[float, timedelta]) -> Self:
737+
super().with_poll_interval(interval)
738+
for _strategy in self._strategies:
739+
_strategy.with_poll_interval(interval)
768740
return self
769741

770-
def with_poll_interval(self, interval: Union[float, timedelta]) -> "CompositeWaitStrategy":
771-
"""
772-
Set the poll interval for all contained strategies.
773-
774-
Args:
775-
interval: How frequently to check in seconds
776-
777-
Returns:
778-
self for method chaining
779-
"""
780-
if isinstance(interval, timedelta):
781-
self._poll_interval = interval.total_seconds()
782-
else:
783-
self._poll_interval = interval
742+
def with_startup_timeout(self, timeout: Union[int, timedelta]) -> Self:
743+
super().with_startup_timeout(timeout)
744+
for _strategy in self._strategies:
745+
_strategy.with_startup_timeout(timeout)
746+
return self
784747

785-
for strategy in self._strategies:
786-
strategy.with_poll_interval(interval)
748+
def with_transient_exceptions(self, *transient_exceptions: type[Exception]) -> Self:
749+
super().with_transient_exceptions(*transient_exceptions)
750+
for _strategy in self._strategies:
751+
_strategy.with_transient_exceptions(*transient_exceptions)
787752
return self
788753

789754
def wait_until_ready(self, container: WaitStrategyTarget) -> None:
@@ -816,6 +781,7 @@ def wait_until_ready(self, container: WaitStrategyTarget) -> None:
816781

817782
__all__ = [
818783
"CompositeWaitStrategy",
784+
"ContainerStatusWaitStrategy",
819785
"FileExistsWaitStrategy",
820786
"HealthcheckWaitStrategy",
821787
"HttpWaitStrategy",

0 commit comments

Comments
 (0)