Skip to content

Commit 9e3efa3

Browse files
Merge branch 'main' into enhanced-wait-strategies-v2
2 parents c3d6a3e + 350f246 commit 9e3efa3

File tree

9 files changed

+341
-26
lines changed

9 files changed

+341
-26
lines changed

core/testcontainers/compose/compose.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ class DockerCompose:
173173
Wait for the services to be healthy
174174
(as per healthcheck definitions in the docker compose configuration)
175175
env_file:
176-
Path to an '.env' file containing environment variables
176+
Path(s) to an '.env' file containing environment variables
177177
to pass to docker compose.
178178
services:
179179
The list of services to use from this DockerCompose.
@@ -210,7 +210,7 @@ class DockerCompose:
210210
build: bool = False
211211
wait: bool = True
212212
keep_volumes: bool = False
213-
env_file: Optional[str] = None
213+
env_file: Optional[Union[str, list[str]]] = None
214214
services: Optional[list[str]] = None
215215
docker_command_path: Optional[str] = None
216216
profiles: Optional[list[str]] = None
@@ -219,6 +219,8 @@ class DockerCompose:
219219
def __post_init__(self) -> None:
220220
if isinstance(self.compose_file_name, str):
221221
self.compose_file_name = [self.compose_file_name]
222+
if isinstance(self.env_file, str):
223+
self.env_file = [self.env_file]
222224

223225
def __enter__(self) -> "DockerCompose":
224226
self.start()
@@ -247,7 +249,8 @@ def compose_command_property(self) -> list[str]:
247249
if self.profiles:
248250
docker_compose_cmd += [item for profile in self.profiles for item in ["--profile", profile]]
249251
if self.env_file:
250-
docker_compose_cmd += ["--env-file", self.env_file]
252+
for env_file in self.env_file:
253+
docker_compose_cmd += ["--env-file", env_file]
251254
return docker_compose_cmd
252255

253256
def waiting_for(self, strategies: dict[str, WaitStrategy]) -> "DockerCompose":

core/testcontainers/core/config.py

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
import docker
1313

14+
ENABLE_FLAGS = ("yes", "true", "t", "y", "1")
15+
1416

1517
class ConnectionMode(Enum):
1618
bridge_ip = "bridge_ip"
@@ -45,16 +47,6 @@ def get_docker_socket() -> str:
4547
return "/var/run/docker.sock"
4648

4749

48-
def get_bool_env(name: str) -> bool:
49-
"""
50-
Get environment variable named `name` and convert it to bool.
51-
52-
Defaults to False.
53-
"""
54-
value = environ.get(name, "")
55-
return value.lower() in ("yes", "true", "t", "y", "1")
56-
57-
5850
TC_FILE = ".testcontainers.properties"
5951
TC_GLOBAL = Path.home() / TC_FILE
6052

@@ -96,11 +88,20 @@ def read_tc_properties() -> dict[str, str]:
9688

9789
@dataclass
9890
class TestcontainersConfiguration:
91+
def _render_bool(self, env_name: str, prop_name: str) -> bool:
92+
env_val = environ.get(env_name, None)
93+
if env_val is not None:
94+
return env_val.lower() in ENABLE_FLAGS
95+
prop_val = self.tc_properties.get(prop_name, None)
96+
if prop_val is not None:
97+
return prop_val.lower() in ENABLE_FLAGS
98+
return False
99+
99100
max_tries: int = int(environ.get("TC_MAX_TRIES", "120"))
100-
sleep_time: int = int(environ.get("TC_POOLING_INTERVAL", "1"))
101+
sleep_time: float = float(environ.get("TC_POOLING_INTERVAL", "1"))
101102
ryuk_image: str = environ.get("RYUK_CONTAINER_IMAGE", "testcontainers/ryuk:0.8.1")
102-
ryuk_privileged: bool = get_bool_env("TESTCONTAINERS_RYUK_PRIVILEGED")
103-
ryuk_disabled: bool = get_bool_env("TESTCONTAINERS_RYUK_DISABLED")
103+
_ryuk_privileged: Optional[bool] = None
104+
_ryuk_disabled: Optional[bool] = None
104105
_ryuk_docker_socket: str = ""
105106
ryuk_reconnection_timeout: str = environ.get("RYUK_RECONNECTION_TIMEOUT", "10s")
106107
tc_properties: dict[str, str] = field(default_factory=read_tc_properties)
@@ -130,7 +131,29 @@ def tc_properties_get_tc_host(self) -> Union[str, None]:
130131
return self.tc_properties.get("tc.host")
131132

132133
@property
133-
def timeout(self) -> int:
134+
def ryuk_privileged(self) -> bool:
135+
if self._ryuk_privileged is not None:
136+
return bool(self._ryuk_privileged)
137+
self._ryuk_privileged = self._render_bool("TESTCONTAINERS_RYUK_PRIVILEGED", "ryuk.container.privileged")
138+
return self._ryuk_privileged
139+
140+
@ryuk_privileged.setter
141+
def ryuk_privileged(self, value: bool) -> None:
142+
self._ryuk_privileged = value
143+
144+
@property
145+
def ryuk_disabled(self) -> bool:
146+
if self._ryuk_disabled is not None:
147+
return bool(self._ryuk_disabled)
148+
self._ryuk_disabled = self._render_bool("TESTCONTAINERS_RYUK_DISABLED", "ryuk.disabled")
149+
return self._ryuk_disabled
150+
151+
@ryuk_disabled.setter
152+
def ryuk_disabled(self, value: bool) -> None:
153+
self._ryuk_disabled = value
154+
155+
@property
156+
def timeout(self) -> float:
134157
return self.max_tries * self.sleep_time
135158

136159
@property

core/testcontainers/core/container.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -226,15 +226,17 @@ def get_container_host_ip(self) -> str:
226226
connection_mode: ConnectionMode
227227
connection_mode = self.get_docker_client().get_connection_mode()
228228

229-
# mypy:
230-
container = self._container
231-
assert container is not None
232-
233229
if connection_mode == ConnectionMode.docker_host:
234230
return self.get_docker_client().host()
235231
elif connection_mode == ConnectionMode.gateway_ip:
232+
# mypy:
233+
container = self._container
234+
assert container is not None
236235
return self.get_docker_client().gateway_ip(container.id)
237236
elif connection_mode == ConnectionMode.bridge_ip:
237+
# mypy:
238+
container = self._container
239+
assert container is not None
238240
return self.get_docker_client().bridge_ip(container.id)
239241
else:
240242
# ensure that we covered all possible connection_modes

core/testcontainers/core/waiting_utils.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,15 +73,15 @@ class WaitStrategy(ABC):
7373
"""Base class for all wait strategies."""
7474

7575
def __init__(self) -> None:
76-
self._startup_timeout: int = config.timeout
76+
self._startup_timeout: float = config.timeout
7777
self._poll_interval: float = 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."""
8181
if isinstance(timeout, timedelta):
82-
self._startup_timeout = int(timeout.total_seconds())
82+
self._startup_timeout = float(int(timeout.total_seconds()))
8383
else:
84-
self._startup_timeout = timeout
84+
self._startup_timeout = float(timeout)
8585
return self
8686

8787
def with_poll_interval(self, interval: Union[float, timedelta]) -> "WaitStrategy":

core/tests/test_config.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,68 @@ def test_read_tc_properties(monkeypatch: MonkeyPatch) -> None:
2828
assert config.tc_properties == {"tc.host": "some_value"}
2929

3030

31+
def test_set_tc_properties(monkeypatch: MonkeyPatch) -> None:
32+
"""
33+
Ensure the configuration file variables can be read if no environment variable is set
34+
"""
35+
with tempfile.TemporaryDirectory() as tmpdirname:
36+
file = f"{tmpdirname}/{TC_FILE}"
37+
with open(file, "w") as f:
38+
f.write("ryuk.disabled=true\n")
39+
f.write("ryuk.container.privileged=false\n")
40+
41+
monkeypatch.setattr("testcontainers.core.config.TC_GLOBAL", file)
42+
43+
config = TCC()
44+
45+
assert config.ryuk_disabled == True
46+
assert config.ryuk_privileged == False
47+
48+
49+
def test_override_tc_properties_1(monkeypatch: MonkeyPatch) -> None:
50+
"""
51+
Ensure that we can re-set the configuration variables programattically to override
52+
testcontainers.properties
53+
"""
54+
with tempfile.TemporaryDirectory() as tmpdirname:
55+
file = f"{tmpdirname}/{TC_FILE}"
56+
with open(file, "w") as f:
57+
f.write("ryuk.disabled=true\n")
58+
f.write("ryuk.container.privileged=false\n")
59+
60+
monkeypatch.setattr("testcontainers.core.config.TC_GLOBAL", file)
61+
62+
config = TCC()
63+
config.ryuk_disabled = False
64+
config.ryuk_privileged = True
65+
66+
assert config.ryuk_disabled == False
67+
assert config.ryuk_privileged == True
68+
69+
70+
def test_override_tc_properties_2(monkeypatch: MonkeyPatch) -> None:
71+
"""
72+
Ensure that we can override the testcontainers.properties with environment variables
73+
"""
74+
with tempfile.TemporaryDirectory() as tmpdirname:
75+
file = f"{tmpdirname}/{TC_FILE}"
76+
with open(file, "w") as f:
77+
f.write("ryuk.disabled=true\n")
78+
f.write("ryuk.container.privileged=false\n")
79+
80+
monkeypatch.setattr("testcontainers.core.config.TC_GLOBAL", file)
81+
82+
import os
83+
84+
os.environ["TESTCONTAINERS_RYUK_DISABLED"] = "false"
85+
os.environ["TESTCONTAINERS_RYUK_PRIVILEGED"] = "true"
86+
87+
config = TCC()
88+
89+
assert config.ryuk_disabled == False
90+
assert config.ryuk_privileged == True
91+
92+
3193
@mark.parametrize("docker_auth_config_env", ["key=value", ""])
3294
@mark.parametrize("warning_dict", [{}, {"key": "value"}, {"DOCKER_AUTH_CONFIG": "TEST"}])
3395
@mark.parametrize("warning_dict_post", [{}, {"key": "value"}, {"DOCKER_AUTH_CONFIG": "TEST"}])

modules/azurite/testcontainers/azurite/__init__.py

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
1111
# License for the specific language governing permissions and limitations
1212
# under the License.
13+
import enum
1314
import os
1415
import socket
1516
from typing import Optional
@@ -19,6 +20,20 @@
1920
from testcontainers.core.waiting_utils import wait_container_is_ready
2021

2122

23+
class ConnectionStringType(enum.Enum):
24+
"""
25+
Enumeration for specifying the type of connection string to generate for Azurite.
26+
27+
:cvar LOCALHOST: Represents a connection string for access from the host machine
28+
where the tests are running.
29+
:cvar NETWORK: Represents a connection string for access from another container
30+
within the same Docker network as the Azurite container.
31+
"""
32+
33+
LOCALHOST = "localhost"
34+
NETWORK = "network"
35+
36+
2237
class AzuriteContainer(DockerContainer):
2338
"""
2439
The example below spins up an Azurite container and
@@ -73,7 +88,45 @@ def __init__(
7388
self.with_exposed_ports(blob_service_port, queue_service_port, table_service_port)
7489
self.with_env("AZURITE_ACCOUNTS", f"{self.account_name}:{self.account_key}")
7590

76-
def get_connection_string(self) -> str:
91+
def get_connection_string(
92+
self, connection_string_type: ConnectionStringType = ConnectionStringType.LOCALHOST
93+
) -> str:
94+
"""Retrieves the appropriate connection string for the Azurite container based on the specified access type.
95+
96+
This method acts as a dispatcher, returning a connection string optimized
97+
either for access from the host machine or for inter-container communication within the same Docker network.
98+
99+
:param connection_string_type: The type of connection string to generate.
100+
Use :attr:`ConnectionStringType.LOCALHOST` for connections
101+
from the machine running the tests (default), or
102+
:attr:`ConnectionStringType.NETWORK` for connections
103+
from other containers within the same Docker network.
104+
:type connection_string_type: ConnectionStringType
105+
:return: The generated Azurite connection string.
106+
:rtype: str
107+
:raises ValueError: If an unrecognized `connection_string_type` is provided.
108+
"""
109+
if connection_string_type == ConnectionStringType.LOCALHOST:
110+
return self.__get_local_connection_string()
111+
elif connection_string_type == ConnectionStringType.NETWORK:
112+
return self.__get_external_connection_string()
113+
else:
114+
raise ValueError(
115+
f"unrecognized connection string type {connection_string_type}, "
116+
f"Supported values are ConnectionStringType.LOCALHOST or ConnectionStringType.NETWORK "
117+
)
118+
119+
def __get_local_connection_string(self) -> str:
120+
"""Generates a connection string for Azurite accessible from the local host machine.
121+
122+
This connection string uses the Docker host IP address (obtained via
123+
:meth:`testcontainers.core.container.DockerContainer.get_container_host_ip`)
124+
and the dynamically exposed ports of the Azurite container. This ensures that
125+
clients running on the host can connect successfully to the Azurite services.
126+
127+
:return: The Azurite connection string for local host access.
128+
:rtype: str
129+
"""
77130
host_ip = self.get_container_host_ip()
78131
connection_string = (
79132
f"DefaultEndpointsProtocol=http;AccountName={self.account_name};AccountKey={self.account_key};"
@@ -96,6 +149,75 @@ def get_connection_string(self) -> str:
96149

97150
return connection_string
98151

152+
def __get_external_connection_string(self) -> str:
153+
"""Generates a connection string for Azurite, primarily optimized for
154+
inter-container communication within a custom Docker network.
155+
156+
This method attempts to provide the most suitable connection string
157+
based on the container's network configuration:
158+
159+
- **For Inter-Container Communication (Recommended):** If the Azurite container is
160+
part of a custom Docker network and has network aliases configured,
161+
the connection string will use the first network alias as the hostname
162+
and the internal container ports (e.g., #$#`http://<alias>:<internal_port>/<account_name>`#$#).
163+
This is the most efficient and robust way for other containers
164+
in the same network to connect to Azurite, leveraging Docker's internal DNS.
165+
166+
- **Fallback for Non-Networked/Aliased Scenarios:** If the container is
167+
not on a custom network with aliases (e.g., running on the default
168+
bridge network without explicit aliases), the method falls back to
169+
using the Docker host IP (obtained via
170+
:meth:`testcontainers.core.container.DockerContainer.get_container_host_ip`)
171+
and the dynamically exposed ports (e.g., #$#`http://<host_ip>:<exposed_port>/<account_name>`#$#).
172+
While this connection string is technically "external" to the container,
173+
it primarily facilitates connections *from the host machine*.
174+
175+
:return: The generated Azurite connection string.
176+
:rtype: str
177+
"""
178+
# Check if we're on a custom network and have network aliases
179+
if hasattr(self, "_network") and self._network and hasattr(self, "_network_aliases") and self._network_aliases:
180+
# Use the first network alias for inter-container communication
181+
host_ip = self._network_aliases[0]
182+
# When using network aliases, use the internal container ports
183+
blob_port = self.blob_service_port
184+
queue_port = self.queue_service_port
185+
table_port = self.table_service_port
186+
else:
187+
# Use the Docker host IP for external connections
188+
host_ip = self.get_container_host_ip()
189+
# When using host IP, use the exposed ports
190+
blob_port = (
191+
self.get_exposed_port(self.blob_service_port)
192+
if self.blob_service_port in self.ports
193+
else self.blob_service_port
194+
)
195+
queue_port = (
196+
self.get_exposed_port(self.queue_service_port)
197+
if self.queue_service_port in self.ports
198+
else self.queue_service_port
199+
)
200+
table_port = (
201+
self.get_exposed_port(self.table_service_port)
202+
if self.table_service_port in self.ports
203+
else self.table_service_port
204+
)
205+
206+
connection_string = (
207+
f"DefaultEndpointsProtocol=http;AccountName={self.account_name};AccountKey={self.account_key};"
208+
)
209+
210+
if self.blob_service_port in self.ports:
211+
connection_string += f"BlobEndpoint=http://{host_ip}:{blob_port}/{self.account_name};"
212+
213+
if self.queue_service_port in self.ports:
214+
connection_string += f"QueueEndpoint=http://{host_ip}:{queue_port}/{self.account_name};"
215+
216+
if self.table_service_port in self.ports:
217+
connection_string += f"TableEndpoint=http://{host_ip}:{table_port}/{self.account_name};"
218+
219+
return connection_string
220+
99221
def start(self) -> "AzuriteContainer":
100222
super().start()
101223
self._connect()
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Use an official Python runtime as a parent image
2+
FROM python:3.10-slim
3+
4+
# Set the working directory in the container
5+
WORKDIR /app
6+
7+
RUN pip install azure-storage-blob==12.19.0
8+
9+
COPY ./netowrk_container.py netowrk_container.py
10+
EXPOSE 80
11+
# Define the command to run the application
12+
CMD ["python", "netowrk_container.py"]

0 commit comments

Comments
 (0)