Skip to content

Commit ed1c991

Browse files
authored
Merge branch 'main' into generic_sql
2 parents fe4604b + bb646e9 commit ed1c991

File tree

11 files changed

+168
-166
lines changed

11 files changed

+168
-166
lines changed

core/testcontainers/compose/compose.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,7 @@ def _ignore_properties(cls: type[_IPT], dict_: Any) -> _IPT:
3636
@dataclass
3737
class PublishedPortModel:
3838
"""
39-
Class that represents the response we get from compose when inquiring status
40-
via `DockerCompose.get_running_containers()`.
39+
Class that represents the response we get from compose when inquiring status via `DockerCompose.get_running_containers()`.
4140
"""
4241

4342
URL: Optional[str] = None
@@ -247,7 +246,9 @@ def docker_compose_command(self) -> list[str]:
247246

248247
@cached_property
249248
def compose_command_property(self) -> list[str]:
250-
docker_compose_cmd = [self.docker_command_path, "compose"] 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+
)
251252
if self.compose_file_name:
252253
for file in self.compose_file_name:
253254
docker_compose_cmd += ["-f", file]
@@ -261,6 +262,7 @@ def compose_command_property(self) -> list[str]:
261262
def waiting_for(self, strategies: dict[str, WaitStrategy]) -> "DockerCompose":
262263
"""
263264
Set wait strategies for specific services.
265+
264266
Args:
265267
strategies: Dictionary mapping service names to wait strategies
266268
"""

core/testcontainers/core/container.py

Lines changed: 10 additions & 9 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
@@ -39,15 +39,11 @@ class DockerContainer:
3939
4040
Args:
4141
image: The name of the image to start.
42-
docker_client_kw: Dictionary with arguments that will be passed to the
43-
docker.DockerClient init.
42+
docker_client_kw: Dictionary with arguments that will be passed to the docker.DockerClient init.
4443
command: Optional execution command for the container.
4544
name: Optional name for the container.
46-
ports: Ports to be exposed by the container. The port number will be
47-
automatically assigned on the host, use
48-
:code:`get_exposed_port(PORT)` method to get the port number on the host.
49-
volumes: Volumes to mount into the container. Each entry should be a tuple with
50-
three values: host path, container path and. mode (default 'ro').
45+
ports: Ports to be exposed by the container. The port number will be automatically assigned on the host, use :code:`get_exposed_port(PORT)` method to get the port number on the host.
46+
volumes: Volumes to mount into the container. Each entry should be a tuple with three values: host path, container path and mode (default 'ro').
5147
network: Optional network to connect the container to.
5248
network_aliases: Optional list of aliases for the container in the network.
5349
@@ -247,8 +243,13 @@ def get_container_host_ip(self) -> str:
247243
# ensure that we covered all possible connection_modes
248244
assert_never(connection_mode)
249245

250-
@wait_container_is_ready()
251246
def get_exposed_port(self, port: int) -> int:
247+
from testcontainers.core.wait_strategies import ContainerStatusWaitStrategy as C
248+
249+
C().wait_until_ready(self)
250+
return self._get_exposed_port(port)
251+
252+
def _get_exposed_port(self, port: int) -> int:
252253
if self.get_docker_client().get_connection_mode().use_mapped_port:
253254
c = self._container
254255
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:
@@ -36,8 +35,11 @@ class DbContainer(DockerContainer):
3635
Generic database container.
3736
"""
3837

39-
@wait_container_is_ready(*ADDITIONAL_TRANSIENT_ERRORS)
4038
def _connect(self) -> None:
39+
from testcontainers.core.wait_strategies import ContainerStatusWaitStrategy as C
40+
41+
C().with_transient_exceptions(*ADDITIONAL_TRANSIENT_ERRORS).wait_until_ready(self)
42+
4143
import sqlalchemy
4244

4345
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)