Skip to content

Commit 7189955

Browse files
authored
Merge branch 'main' into typed_docker_client
2 parents b8e76bc + 8d77bd3 commit 7189955

File tree

13 files changed

+167
-21
lines changed

13 files changed

+167
-21
lines changed
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
".": "4.8.2"
2+
".": "4.9.0"
33
}

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,28 @@
11
# Changelog
22

3+
## [4.9.0](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.8.2...testcontainers-v4.9.0) (2024-11-26)
4+
5+
6+
### Features
7+
8+
* **compose:** support for setting profiles ([#738](https://github.com/testcontainers/testcontainers-python/issues/738)) ([3e00e71](https://github.com/testcontainers/testcontainers-python/commit/3e00e71da4d2b5e7fd30315468d4e54c86ba6150))
9+
* **core:** Support working with env files ([#737](https://github.com/testcontainers/testcontainers-python/issues/737)) ([932ee30](https://github.com/testcontainers/testcontainers-python/commit/932ee307955e3591a63f194aee8e2f6d8e2f6bf9))
10+
11+
12+
### Bug Fixes
13+
14+
* allow running all tests ([#721](https://github.com/testcontainers/testcontainers-python/issues/721)) ([f958cf9](https://github.com/testcontainers/testcontainers-python/commit/f958cf9fe62a5f3ee2dc255713ec8b16de6a767d))
15+
* **core:** Avoid hanging upon bad docker host connection ([#742](https://github.com/testcontainers/testcontainers-python/issues/742)) ([4ced198](https://github.com/testcontainers/testcontainers-python/commit/4ced1983162914fe511a6e714f136b670e1dbdfb))
16+
* **core:** running testcontainer inside container ([#714](https://github.com/testcontainers/testcontainers-python/issues/714)) ([85a6666](https://github.com/testcontainers/testcontainers-python/commit/85a66667c23d76e87aecc6761bbb01429adb3dee))
17+
* **generic:** Also catch URLError waiting for ServerContainer ([#743](https://github.com/testcontainers/testcontainers-python/issues/743)) ([24e354f](https://github.com/testcontainers/testcontainers-python/commit/24e354f3bfa5912eaf7877da9442a885d7872f1a))
18+
* update wait_for_logs to not throw on 'created', and an optimization ([#719](https://github.com/testcontainers/testcontainers-python/issues/719)) ([271ca9a](https://github.com/testcontainers/testcontainers-python/commit/271ca9a0fef2e5f2b216457bfee44318e93990bf))
19+
* Vault health check ([#734](https://github.com/testcontainers/testcontainers-python/issues/734)) ([79434d6](https://github.com/testcontainers/testcontainers-python/commit/79434d6744b2918493884cf8fbf27aeadf78ecfd))
20+
21+
22+
### Documentation
23+
24+
* Documentation fix for ServerContainer ([#671](https://github.com/testcontainers/testcontainers-python/issues/671)) ([0303d47](https://github.com/testcontainers/testcontainers-python/commit/0303d47d7173e1c4ec1a4f565efee9b2fe694928))
25+
326
## [4.8.2](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.8.1...testcontainers-v4.8.2) (2024-09-27)
427

528

core/testcontainers/compose/compose.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ class DockerCompose:
171171
env_file: Optional[str] = None
172172
services: Optional[list[str]] = None
173173
docker_command_path: Optional[str] = None
174+
profiles: Optional[list[str]] = None
174175

175176
def __post_init__(self):
176177
if isinstance(self.compose_file_name, str):
@@ -198,6 +199,8 @@ def compose_command_property(self) -> list[str]:
198199
if self.compose_file_name:
199200
for file in self.compose_file_name:
200201
docker_compose_cmd += ["-f", file]
202+
if self.profiles:
203+
docker_compose_cmd += [item for profile in self.profiles for item in ["--profile", profile]]
201204
if self.env_file:
202205
docker_compose_cmd += ["--env-file", self.env_file]
203206
return docker_compose_cmd

core/testcontainers/core/container.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
import contextlib
2+
from os import PathLike
23
from socket import socket
34
from typing import TYPE_CHECKING, Optional, Union
45

56
import docker.errors
67
from docker import version
78
from docker.types import EndpointConfig
9+
from dotenv import dotenv_values
810
from typing_extensions import Self, assert_never
911

1012
from testcontainers.core.config import ConnectionMode
1113
from testcontainers.core.config import testcontainers_config as c
1214
from testcontainers.core.docker_client import DockerClient
13-
from testcontainers.core.exceptions import ContainerStartException
15+
from testcontainers.core.exceptions import ContainerConnectException, ContainerStartException
1416
from testcontainers.core.labels import LABEL_SESSION_ID, SESSION_ID
1517
from testcontainers.core.network import Network
1618
from testcontainers.core.utils import is_arm, setup_logger
@@ -57,6 +59,12 @@ def with_env(self, key: str, value: str) -> Self:
5759
self.env[key] = value
5860
return self
5961

62+
def with_env_file(self, env_file: Union[str, PathLike]) -> Self:
63+
env_values = dotenv_values(env_file)
64+
for key, value in env_values.items():
65+
self.with_env(key, value)
66+
return self
67+
6068
def with_bind_ports(self, container: int, host: Optional[int] = None) -> Self:
6169
self.ports[container] = host
6270
return self
@@ -220,15 +228,21 @@ def _create_instance(cls) -> "Reaper":
220228
.with_env("RYUK_RECONNECTION_TIMEOUT", c.ryuk_reconnection_timeout)
221229
.start()
222230
)
223-
wait_for_logs(Reaper._container, r".* Started!")
231+
wait_for_logs(Reaper._container, r".* Started!", timeout=20, raise_on_exit=True)
224232

225233
container_host = Reaper._container.get_container_host_ip()
226234
container_port = int(Reaper._container.get_exposed_port(8080))
227235

236+
if not container_host or not container_port:
237+
raise ContainerConnectException(
238+
f"Could not obtain network details for {Reaper._container._container.id}. Host: {container_host} Port: {container_port}"
239+
)
240+
228241
last_connection_exception: Optional[Exception] = None
229242
for _ in range(50):
230243
try:
231244
Reaper._socket = socket()
245+
Reaper._socket.settimeout(1)
232246
Reaper._socket.connect((container_host, container_port))
233247
last_connection_exception = None
234248
break

core/testcontainers/core/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ class ContainerStartException(RuntimeError):
1616
pass
1717

1818

19+
class ContainerConnectException(RuntimeError):
20+
pass
21+
22+
1923
class ContainerIsNotRunning(RuntimeError):
2024
pass
2125

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
services:
2+
runs-always: &simple-service
3+
image: alpine:latest
4+
init: true
5+
command:
6+
- sh
7+
- -c
8+
- 'while true; do sleep 0.1 ; date -Ins; done'
9+
runs-profile-a:
10+
<<: *simple-service
11+
profiles:
12+
- profile-a
13+
runs-profile-b:
14+
<<: *simple-service
15+
profiles:
16+
- profile-b

core/tests/test_compose.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from pathlib import Path
33
from re import split
44
from time import sleep
5-
from typing import Union
5+
from typing import Union, Optional
66
from urllib.request import urlopen, Request
77

88
import pytest
@@ -352,3 +352,27 @@ def fetch(req: Union[Request, str]):
352352
if 200 < res.getcode() >= 400:
353353
raise Exception(f"HTTP Error: {res.getcode()} - {res.reason}: {body}")
354354
return res.getcode(), body
355+
356+
357+
@pytest.mark.parametrize(
358+
argnames=["profiles", "running", "not_running"],
359+
argvalues=[
360+
pytest.param(None, ["runs-always"], ["runs-profile-a", "runs-profile-b"], id="default"),
361+
pytest.param(
362+
["profile-a"], ["runs-always", "runs-profile-a"], ["runs-profile-b"], id="one-additional-profile-via-str"
363+
),
364+
pytest.param(
365+
["profile-a", "profile-b"],
366+
["runs-always", "runs-profile-a", "runs-profile-b"],
367+
[],
368+
id="all-profiles-explicitly",
369+
),
370+
],
371+
)
372+
def test_compose_profile_support(profiles: Optional[list[str]], running: list[str], not_running: list[str]):
373+
with DockerCompose(context=FIXTURES / "profile_support", profiles=profiles) as compose:
374+
for service in running:
375+
assert compose.get_container(service) is not None
376+
for service in not_running:
377+
with pytest.raises(ContainerIsNotRunning):
378+
compose.get_container(service)

core/tests/test_core.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import tempfile
2+
from pathlib import Path
3+
14
from testcontainers.core.container import DockerContainer
25

36

@@ -17,3 +20,29 @@ def test_get_logs():
1720
assert isinstance(stdout, bytes)
1821
assert isinstance(stderr, bytes)
1922
assert "Hello from Docker".encode() in stdout, "There should be something on stdout"
23+
24+
25+
def test_docker_container_with_env_file():
26+
"""Test that environment variables can be loaded from a file"""
27+
with tempfile.TemporaryDirectory() as temp_directory:
28+
env_file_path = Path(temp_directory) / "env_file"
29+
with open(env_file_path, "w") as f:
30+
f.write(
31+
"""
32+
TEST_ENV_VAR=hello
33+
NUMBER=123
34+
DOMAIN=example.org
35+
ADMIN_EMAIL=admin@${DOMAIN}
36+
ROOT_URL=${DOMAIN}/app
37+
"""
38+
)
39+
container = DockerContainer("alpine").with_command("tail -f /dev/null") # Keep the container running
40+
container.with_env_file(env_file_path) # Load the environment variables from the file
41+
with container:
42+
output = container.exec("env").output.decode("utf-8").strip()
43+
assert "TEST_ENV_VAR=hello" in output
44+
assert "NUMBER=123" in output
45+
assert "DOMAIN=example.org" in output
46+
assert "[email protected]" in output
47+
assert "ROOT_URL=example.org/app" in output
48+
print(output)

modules/generic/testcontainers/generic/server.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from typing import Union
2-
from urllib.error import HTTPError
2+
from urllib.error import HTTPError, URLError
33
from urllib.request import urlopen
44

55
import httpx
@@ -40,7 +40,7 @@ def __init__(self, port: int, image: Union[str, DockerImage]) -> None:
4040
self.internal_port = port
4141
self.with_exposed_ports(self.internal_port)
4242

43-
@wait_container_is_ready(HTTPError)
43+
@wait_container_is_ready(HTTPError, URLError)
4444
def _connect(self) -> None:
4545
# noinspection HttpUrlsUsage
4646
url = self._create_connection_url()

modules/mysql/testcontainers/mysql/__init__.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,14 @@ class MySqlContainer(DbContainer):
3131
The example will spin up a MySql database to which you can connect with the credentials
3232
passed in the constructor. Alternatively, you may use the :code:`get_connection_url()`
3333
method which returns a sqlalchemy-compatible url in format
34-
:code:`dialect+driver://username:password@host:port/database`.
34+
:code:`mysql+dialect://username:password@host:port/database`.
3535
3636
.. doctest::
3737
3838
>>> import sqlalchemy
3939
>>> from testcontainers.mysql import MySqlContainer
4040
41-
>>> with MySqlContainer('mysql:5.7.17') as mysql:
41+
>>> with MySqlContainer("mysql:5.7.17", dialect="pymysql") as mysql:
4242
... engine = sqlalchemy.create_engine(mysql.get_connection_url())
4343
... with engine.begin() as connection:
4444
... result = connection.execute(sqlalchemy.text("select version()"))
@@ -64,6 +64,7 @@ class MySqlContainer(DbContainer):
6464
def __init__(
6565
self,
6666
image: str = "mysql:latest",
67+
dialect: Optional[str] = None,
6768
username: Optional[str] = None,
6869
root_password: Optional[str] = None,
6970
password: Optional[str] = None,
@@ -72,6 +73,10 @@ def __init__(
7273
seed: Optional[str] = None,
7374
**kwargs,
7475
) -> None:
76+
if dialect is not None and dialect.startswith("mysql+"):
77+
msg = "Please remove 'mysql+' prefix from dialect parameter"
78+
raise ValueError(msg)
79+
7580
raise_for_deprecated_parameter(kwargs, "MYSQL_USER", "username")
7681
raise_for_deprecated_parameter(kwargs, "MYSQL_ROOT_PASSWORD", "root_password")
7782
raise_for_deprecated_parameter(kwargs, "MYSQL_PASSWORD", "password")
@@ -85,6 +90,9 @@ def __init__(
8590
self.password = password or environ.get("MYSQL_PASSWORD", "test")
8691
self.dbname = dbname or environ.get("MYSQL_DATABASE", "test")
8792

93+
self.dialect = dialect or environ.get("MYSQL_DIALECT", None)
94+
self._db_url_dialect_part = "mysql" if self.dialect is None else f"mysql+{self.dialect}"
95+
8896
if self.username == "root":
8997
self.root_password = self.password
9098
self.seed = seed
@@ -105,7 +113,11 @@ def _connect(self) -> None:
105113

106114
def get_connection_url(self) -> str:
107115
return super()._create_connection_url(
108-
dialect="mysql+pymysql", username=self.username, password=self.password, dbname=self.dbname, port=self.port
116+
dialect=self._db_url_dialect_part,
117+
username=self.username,
118+
password=self.password,
119+
dbname=self.dbname,
120+
port=self.port,
109121
)
110122

111123
def _transfer_seed(self) -> None:

0 commit comments

Comments
 (0)