Skip to content

Commit 3be6da3

Browse files
fix(core): make config editable to avoid monkeypatching.1 (#532)
see #531: I am using testcontainers within a library that provides some pytest-fixtures. In order for this to work I have change some settings. As I can not guarantee that that my lib is imported before testcontainers I need to monkeypatch the settings. This is much easier if I only need to monkeypatch the config file and not all modules that use configurations. I would argue that for a potential library as this, this is a better design. Also one can easier see that the given UPERCASE variable is not a constant but rather a setting. Co-authored-by: Carli* Freudenberg <[email protected]>
1 parent 1326278 commit 3be6da3

File tree

11 files changed

+91
-60
lines changed

11 files changed

+91
-60
lines changed

core/testcontainers/core/config.py

+61
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
from dataclasses import dataclass, field
12
from os import environ
3+
from os.path import exists
4+
from pathlib import Path
25

36
MAX_TRIES = int(environ.get("TC_MAX_TRIES", 120))
47
SLEEP_TIME = int(environ.get("TC_POOLING_INTERVAL", 1))
@@ -9,3 +12,61 @@
912
RYUK_DISABLED: bool = environ.get("TESTCONTAINERS_RYUK_DISABLED", "false") == "true"
1013
RYUK_DOCKER_SOCKET: str = environ.get("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE", "/var/run/docker.sock")
1114
RYUK_RECONNECTION_TIMEOUT: str = environ.get("RYUK_RECONNECTION_TIMEOUT", "10s")
15+
16+
TC_FILE = ".testcontainers.properties"
17+
TC_GLOBAL = Path.home() / TC_FILE
18+
19+
20+
def read_tc_properties() -> dict[str, str]:
21+
"""
22+
Read the .testcontainers.properties for settings. (see the Java implementation for details)
23+
Currently we only support the ~/.testcontainers.properties but may extend to per-project variables later.
24+
25+
:return: the merged properties from the sources.
26+
"""
27+
tc_files = [item for item in [TC_GLOBAL] if exists(item)]
28+
if not tc_files:
29+
return {}
30+
settings = {}
31+
32+
for file in tc_files:
33+
with open(file) as contents:
34+
tuples = [line.split("=") for line in contents.readlines() if "=" in line]
35+
settings = {**settings, **{item[0].strip(): item[1].strip() for item in tuples}}
36+
return settings
37+
38+
39+
@dataclass
40+
class TestcontainersConfiguration:
41+
max_tries: int = MAX_TRIES
42+
sleep_time: int = SLEEP_TIME
43+
ryuk_image: str = RYUK_IMAGE
44+
ryuk_privileged: bool = RYUK_PRIVILEGED
45+
ryuk_disabled: bool = RYUK_DISABLED
46+
ryuk_docker_socket: str = RYUK_DOCKER_SOCKET
47+
ryuk_reconnection_timeout: str = RYUK_RECONNECTION_TIMEOUT
48+
tc_properties: dict[str, str] = field(default_factory=read_tc_properties)
49+
50+
def tc_properties_get_tc_host(self):
51+
return self.tc_properties.get("tc.host")
52+
53+
@property
54+
def timeout(self):
55+
return self.max_tries * self.sleep_time
56+
57+
58+
testcontainers_config = TestcontainersConfiguration()
59+
60+
__all__ = [
61+
# the public API of this module
62+
"testcontainers_config",
63+
# and all the legacy things that are deprecated:
64+
"MAX_TRIES",
65+
"SLEEP_TIME",
66+
"TIMEOUT",
67+
"RYUK_IMAGE",
68+
"RYUK_PRIVILEGED",
69+
"RYUK_DISABLED",
70+
"RYUK_DOCKER_SOCKET",
71+
"RYUK_RECONNECTION_TIMEOUT",
72+
]

core/testcontainers/core/container.py

+6-12
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,7 @@
66
import docker.errors
77
from typing_extensions import Self
88

9-
from testcontainers.core.config import (
10-
RYUK_DISABLED,
11-
RYUK_DOCKER_SOCKET,
12-
RYUK_IMAGE,
13-
RYUK_PRIVILEGED,
14-
RYUK_RECONNECTION_TIMEOUT,
15-
)
9+
from testcontainers.core.config import testcontainers_config as c
1610
from testcontainers.core.docker_client import DockerClient
1711
from testcontainers.core.exceptions import ContainerStartException
1812
from testcontainers.core.labels import LABEL_SESSION_ID, SESSION_ID
@@ -77,7 +71,7 @@ def maybe_emulate_amd64(self) -> Self:
7771
return self
7872

7973
def start(self) -> Self:
80-
if not RYUK_DISABLED and self.image != RYUK_IMAGE:
74+
if not c.ryuk_disabled and self.image != c.ryuk_image:
8175
logger.debug("Creating Ryuk container")
8276
Reaper.get_instance()
8377
logger.info("Pulling image %s", self.image)
@@ -201,12 +195,12 @@ def _create_instance(cls) -> "Reaper":
201195
logger.debug(f"Creating new Reaper for session: {SESSION_ID}")
202196

203197
Reaper._container = (
204-
DockerContainer(RYUK_IMAGE)
198+
DockerContainer(c.ryuk_image)
205199
.with_name(f"testcontainers-ryuk-{SESSION_ID}")
206200
.with_exposed_ports(8080)
207-
.with_volume_mapping(RYUK_DOCKER_SOCKET, "/var/run/docker.sock", "rw")
208-
.with_kwargs(privileged=RYUK_PRIVILEGED, auto_remove=True)
209-
.with_env("RYUK_RECONNECTION_TIMEOUT", RYUK_RECONNECTION_TIMEOUT)
201+
.with_volume_mapping(c.ryuk_docker_socket, "/var/run/docker.sock", "rw")
202+
.with_kwargs(privileged=c.ryuk_privileged, auto_remove=True)
203+
.with_env("RYUK_RECONNECTION_TIMEOUT", c.ryuk_reconnection_timeout)
210204
.start()
211205
)
212206
wait_for_logs(Reaper._container, r".* Started!")

core/testcontainers/core/docker_client.py

+2-26
Original file line numberDiff line numberDiff line change
@@ -16,20 +16,17 @@
1616
import os
1717
import urllib
1818
import urllib.parse
19-
from os.path import exists
20-
from pathlib import Path
2119
from typing import Callable, Optional, TypeVar, Union
2220

2321
import docker
2422
from docker.models.containers import Container, ContainerCollection
2523
from typing_extensions import ParamSpec
2624

25+
from testcontainers.core.config import testcontainers_config as c
2726
from testcontainers.core.labels import SESSION_ID, create_labels
2827
from testcontainers.core.utils import default_gateway_ip, inside_container, setup_logger
2928

3029
LOGGER = setup_logger(__name__)
31-
TC_FILE = ".testcontainers.properties"
32-
TC_GLOBAL = Path.home() / TC_FILE
3330

3431
_P = ParamSpec("_P")
3532
_T = TypeVar("_T")
@@ -185,26 +182,5 @@ def host(self) -> str:
185182
return "localhost"
186183

187184

188-
@ft.cache
189-
def read_tc_properties() -> dict[str, str]:
190-
"""
191-
Read the .testcontainers.properties for settings. (see the Java implementation for details)
192-
Currently we only support the ~/.testcontainers.properties but may extend to per-project variables later.
193-
194-
:return: the merged properties from the sources.
195-
"""
196-
tc_files = [item for item in [TC_GLOBAL] if exists(item)]
197-
if not tc_files:
198-
return {}
199-
settings = {}
200-
201-
for file in tc_files:
202-
tuples = []
203-
with open(file) as contents:
204-
tuples = [line.split("=") for line in contents.readlines() if "=" in line]
205-
settings = {**settings, **{item[0].strip(): item[1].strip() for item in tuples}}
206-
return settings
207-
208-
209185
def get_docker_host() -> Optional[str]:
210-
return read_tc_properties().get("tc.host") or os.getenv("DOCKER_HOST")
186+
return c.tc_properties_get_tc_host() or os.getenv("DOCKER_HOST")

core/testcontainers/core/labels.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from typing import Optional
22
from uuid import uuid4
33

4-
from testcontainers.core.config import RYUK_IMAGE
4+
from testcontainers.core.config import testcontainers_config as c
55

66
SESSION_ID: str = str(uuid4())
77
LABEL_SESSION_ID = "org.testcontainers.session-id"
@@ -13,7 +13,7 @@ def create_labels(image: str, labels: Optional[dict[str, str]]) -> dict[str, str
1313
labels = {}
1414
labels[LABEL_LANG] = "python"
1515

16-
if image == RYUK_IMAGE:
16+
if image == c.ryuk_image:
1717
return labels
1818

1919
labels[LABEL_SESSION_ID] = SESSION_ID

core/testcontainers/core/waiting_utils.py

+5-5
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
import wrapt
2121

22-
from testcontainers.core import config
22+
from testcontainers.core.config import testcontainers_config as config
2323
from testcontainers.core.utils import setup_logger
2424

2525
if TYPE_CHECKING:
@@ -54,18 +54,18 @@ def wrapper(wrapped: Callable, instance: Any, args: list, kwargs: dict) -> Any:
5454
logger.info("Waiting for %s to be ready ...", instance)
5555

5656
exception = None
57-
for attempt_no in range(config.MAX_TRIES):
57+
for attempt_no in range(config.max_tries):
5858
try:
5959
return wrapped(*args, **kwargs)
6060
except transient_exceptions as e:
6161
logger.debug(
62-
f"Connection attempt '{attempt_no + 1}' of '{config.MAX_TRIES + 1}' "
62+
f"Connection attempt '{attempt_no + 1}' of '{config.max_tries + 1}' "
6363
f"failed: {traceback.format_exc()}"
6464
)
65-
time.sleep(config.SLEEP_TIME)
65+
time.sleep(config.sleep_time)
6666
exception = e
6767
raise TimeoutError(
68-
f"Wait time ({config.TIMEOUT}s) exceeded for {wrapped.__name__}(args: {args}, kwargs: "
68+
f"Wait time ({config.timeout}s) exceeded for {wrapped.__name__}(args: {args}, kwargs: "
6969
f"{kwargs}). Exception: {exception}"
7070
)
7171

core/tests/test_ryuk.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,15 @@
55
from docker import DockerClient
66
from docker.errors import NotFound
77

8-
from testcontainers.core import container as container_module
8+
from testcontainers.core.config import testcontainers_config
99
from testcontainers.core.container import Reaper
1010
from testcontainers.core.container import DockerContainer
1111
from testcontainers.core.waiting_utils import wait_for_logs
1212

1313

1414
def test_wait_for_reaper(monkeypatch: MonkeyPatch):
1515
Reaper.delete_instance()
16-
monkeypatch.setattr(container_module, "RYUK_RECONNECTION_TIMEOUT", "0.1s")
16+
monkeypatch.setattr(testcontainers_config, "ryuk_reconnection_timeout", "0.1s")
1717
docker_client = DockerClient()
1818
container = DockerContainer("hello-world").start()
1919

@@ -40,7 +40,7 @@ def test_wait_for_reaper(monkeypatch: MonkeyPatch):
4040

4141
def test_container_without_ryuk(monkeypatch: MonkeyPatch):
4242
Reaper.delete_instance()
43-
monkeypatch.setattr(container_module, "RYUK_DISABLED", True)
43+
monkeypatch.setattr(testcontainers_config, "ryuk_disabled", True)
4444
with DockerContainer("hello-world") as container:
4545
wait_for_logs(container, "Hello from Docker!")
4646
assert Reaper._instance is None

modules/arangodb/testcontainers/arangodb/__init__.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import typing
66
from os import environ
77

8-
from testcontainers.core.config import TIMEOUT
8+
from testcontainers.core.config import testcontainers_config as c
99
from testcontainers.core.generic import DbContainer
1010
from testcontainers.core.utils import raise_for_deprecated_parameter
1111
from testcontainers.core.waiting_utils import wait_for_logs
@@ -90,4 +90,4 @@ def get_connection_url(self) -> str:
9090
return f"http://{self.get_container_host_ip()}:{port}"
9191

9292
def _connect(self) -> None:
93-
wait_for_logs(self, predicate="is ready for business", timeout=TIMEOUT)
93+
wait_for_logs(self, predicate="is ready for business", timeout=c.timeout)

modules/k3s/testcontainers/k3s/__init__.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
# License for the specific language governing permissions and limitations
1212
# under the License.
1313

14-
from testcontainers.core.config import MAX_TRIES
14+
from testcontainers.core.config import testcontainers_config
1515
from testcontainers.core.container import DockerContainer
1616
from testcontainers.core.waiting_utils import wait_for_logs
1717

@@ -46,7 +46,7 @@ def __init__(self, image="rancher/k3s:latest", **kwargs) -> None:
4646
self.with_volume_mapping("/sys/fs/cgroup", "/sys/fs/cgroup", "rw")
4747

4848
def _connect(self) -> None:
49-
wait_for_logs(self, predicate="Node controller sync successful", timeout=MAX_TRIES)
49+
wait_for_logs(self, predicate="Node controller sync successful", timeout=testcontainers_config.timeout)
5050

5151
def start(self) -> "K3SContainer":
5252
super().start()

modules/neo4j/testcontainers/neo4j/__init__.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from typing import Optional
1616

1717
from neo4j import Driver, GraphDatabase
18-
from testcontainers.core.config import TIMEOUT
18+
from testcontainers.core.config import testcontainers_config as c
1919
from testcontainers.core.generic import DbContainer
2020
from testcontainers.core.utils import raise_for_deprecated_parameter
2121
from testcontainers.core.waiting_utils import wait_container_is_ready, wait_for_logs
@@ -62,7 +62,7 @@ def get_connection_url(self) -> str:
6262

6363
@wait_container_is_ready()
6464
def _connect(self) -> None:
65-
wait_for_logs(self, "Remote interface available at", TIMEOUT)
65+
wait_for_logs(self, "Remote interface available at", c.timeout)
6666

6767
# Then we actually check that the container really is listening
6868
with self.get_driver() as driver:

modules/postgres/testcontainers/postgres/__init__.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from time import sleep
1515
from typing import Optional
1616

17-
from testcontainers.core.config import MAX_TRIES, SLEEP_TIME
17+
from testcontainers.core.config import testcontainers_config as c
1818
from testcontainers.core.generic import DbContainer
1919
from testcontainers.core.utils import raise_for_deprecated_parameter
2020
from testcontainers.core.waiting_utils import wait_container_is_ready, wait_for_logs
@@ -91,15 +91,15 @@ def get_connection_url(self, host: Optional[str] = None, driver: Optional[str] =
9191

9292
@wait_container_is_ready()
9393
def _connect(self) -> None:
94-
wait_for_logs(self, ".*database system is ready to accept connections.*", MAX_TRIES, SLEEP_TIME)
94+
wait_for_logs(self, ".*database system is ready to accept connections.*", c.max_tries, c.sleep_time)
9595

9696
count = 0
97-
while count < MAX_TRIES:
97+
while count < c.max_tries:
9898
status, _ = self.exec(f"pg_isready -hlocalhost -p{self.port} -U{self.username}")
9999
if status == 0:
100100
return
101101

102-
sleep(SLEEP_TIME)
102+
sleep(c.sleep_time)
103103
count += 1
104104

105105
raise RuntimeError("Postgres could not get into a ready state")

modules/qdrant/testcontainers/qdrant/__init__.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from pathlib import Path
1616
from typing import Optional
1717

18-
from testcontainers.core.config import TIMEOUT
18+
from testcontainers.core.config import testcontainers_config as c
1919
from testcontainers.core.generic import DbContainer
2020
from testcontainers.core.waiting_utils import wait_container_is_ready, wait_for_logs
2121

@@ -61,7 +61,7 @@ def _configure(self) -> None:
6161

6262
@wait_container_is_ready()
6363
def _connect(self) -> None:
64-
wait_for_logs(self, ".*Actix runtime found; starting in Actix runtime.*", TIMEOUT)
64+
wait_for_logs(self, ".*Actix runtime found; starting in Actix runtime.*", c.timeout)
6565

6666
def get_client(self, **kwargs):
6767
"""

0 commit comments

Comments
 (0)