Skip to content

Commit ff6a32d

Browse files
feat: make config monkeypatchable, fix config related startup issues (#833)
Make configuration monkeypatchable. Also show users how within readme. This should also fix the bug in #830 and supersedes #821 and #832 --------- Co-authored-by: David Ankin <[email protected]>
1 parent f467c84 commit ff6a32d

File tree

7 files changed

+107
-36
lines changed

7 files changed

+107
-36
lines changed

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,20 @@ See [CONTRIBUTING.md](.github/CONTRIBUTING.md) for more details.
4141

4242
## Configuration
4343

44+
You can set environment variables to configure the library behaviour:
45+
4446
| Env Variable | Example | Description |
4547
| --------------------------------------- | --------------------------- | ---------------------------------------------------------------------------------- |
4648
| `TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE` | `/var/run/docker.sock` | Path to Docker's socket used by ryuk |
4749
| `TESTCONTAINERS_RYUK_PRIVILEGED` | `false` | Run ryuk as a privileged container |
4850
| `TESTCONTAINERS_RYUK_DISABLED` | `false` | Disable ryuk |
4951
| `RYUK_CONTAINER_IMAGE` | `testcontainers/ryuk:0.8.1` | Custom image for ryuk |
5052
| `RYUK_RECONNECTION_TIMEOUT` | `10s` | Reconnection timeout for Ryuk TCP socket before Ryuk reaps all dangling containers |
53+
54+
Alternatively you can set the configuration during runtime:
55+
56+
```python
57+
from testcontainers.core import testcontainers_config
58+
59+
testcontainers_config.ryuk_docker_socket = "/home/user/docker.sock"
60+
```

core/testcontainers/core/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .config import testcontainers_config
2+
3+
__all__ = ["testcontainers_config"]

core/testcontainers/core/config.py

Lines changed: 68 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
import types
2+
import warnings
3+
from collections.abc import Mapping
14
from dataclasses import dataclass, field
25
from enum import Enum
36
from logging import warning
47
from os import environ
58
from os.path import exists
69
from pathlib import Path
7-
from typing import Optional, Union
10+
from typing import Final, Optional, Union
811

912
import docker
1013

@@ -30,28 +33,27 @@ def get_docker_socket() -> str:
3033
3134
Using the docker api ensure we handle rootless docker properly
3235
"""
33-
if socket_path := environ.get("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE"):
36+
if socket_path := environ.get("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE", ""):
3437
return socket_path
3538

36-
client = docker.from_env()
3739
try:
40+
client = docker.from_env()
3841
socket_path = client.api.get_adapter(client.api.base_url).socket_path
3942
# return the normalized path as string
4043
return str(Path(socket_path).absolute())
41-
except AttributeError:
44+
except Exception:
4245
return "/var/run/docker.sock"
4346

4447

45-
MAX_TRIES = int(environ.get("TC_MAX_TRIES", 120))
46-
SLEEP_TIME = int(environ.get("TC_POOLING_INTERVAL", 1))
47-
TIMEOUT = MAX_TRIES * SLEEP_TIME
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")
4856

49-
RYUK_IMAGE: str = environ.get("RYUK_CONTAINER_IMAGE", "testcontainers/ryuk:0.8.1")
50-
RYUK_PRIVILEGED: bool = environ.get("TESTCONTAINERS_RYUK_PRIVILEGED", "false") == "true"
51-
RYUK_DISABLED: bool = environ.get("TESTCONTAINERS_RYUK_DISABLED", "false") == "true"
52-
RYUK_DOCKER_SOCKET: str = get_docker_socket()
53-
RYUK_RECONNECTION_TIMEOUT: str = environ.get("RYUK_RECONNECTION_TIMEOUT", "10s")
54-
TC_HOST_OVERRIDE: Optional[str] = environ.get("TC_HOST", environ.get("TESTCONTAINERS_HOST_OVERRIDE"))
5557

5658
TC_FILE = ".testcontainers.properties"
5759
TC_GLOBAL = Path.home() / TC_FILE
@@ -94,16 +96,16 @@ def read_tc_properties() -> dict[str, str]:
9496

9597
@dataclass
9698
class TestcontainersConfiguration:
97-
max_tries: int = MAX_TRIES
98-
sleep_time: int = SLEEP_TIME
99-
ryuk_image: str = RYUK_IMAGE
100-
ryuk_privileged: bool = RYUK_PRIVILEGED
101-
ryuk_disabled: bool = RYUK_DISABLED
102-
ryuk_docker_socket: str = RYUK_DOCKER_SOCKET
103-
ryuk_reconnection_timeout: str = RYUK_RECONNECTION_TIMEOUT
99+
max_tries: int = int(environ.get("TC_MAX_TRIES", "120"))
100+
sleep_time: int = int(environ.get("TC_POOLING_INTERVAL", "1"))
101+
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")
104+
_ryuk_docker_socket: str = ""
105+
ryuk_reconnection_timeout: str = environ.get("RYUK_RECONNECTION_TIMEOUT", "10s")
104106
tc_properties: dict[str, str] = field(default_factory=read_tc_properties)
105107
_docker_auth_config: Optional[str] = field(default_factory=lambda: environ.get("DOCKER_AUTH_CONFIG"))
106-
tc_host_override: Optional[str] = TC_HOST_OVERRIDE
108+
tc_host_override: Optional[str] = environ.get("TC_HOST", environ.get("TESTCONTAINERS_HOST_OVERRIDE"))
107109
connection_mode_override: Optional[ConnectionMode] = field(default_factory=get_user_overwritten_connection_mode)
108110

109111
"""
@@ -131,19 +133,54 @@ def tc_properties_get_tc_host(self) -> Union[str, None]:
131133
def timeout(self) -> int:
132134
return self.max_tries * self.sleep_time
133135

136+
@property
137+
def ryuk_docker_socket(self) -> str:
138+
if not self._ryuk_docker_socket:
139+
self.ryuk_docker_socket = get_docker_socket()
140+
return self._ryuk_docker_socket
134141

135-
testcontainers_config = TestcontainersConfiguration()
142+
@ryuk_docker_socket.setter
143+
def ryuk_docker_socket(self, value: str) -> None:
144+
self._ryuk_docker_socket = value
145+
146+
147+
testcontainers_config: Final = TestcontainersConfiguration()
136148

137149
__all__ = [
138-
# Legacy things that are deprecated:
139-
"MAX_TRIES",
140-
"RYUK_DISABLED",
141-
"RYUK_DOCKER_SOCKET",
142-
"RYUK_IMAGE",
143-
"RYUK_PRIVILEGED",
144-
"RYUK_RECONNECTION_TIMEOUT",
145-
"SLEEP_TIME",
146-
"TIMEOUT",
147150
# Public API of this module:
148151
"testcontainers_config",
149152
]
153+
154+
_deprecated_attribute_mapping: Final[Mapping[str, str]] = types.MappingProxyType(
155+
{
156+
"MAX_TRIES": "max_tries",
157+
"RYUK_DISABLED": "ryuk_disabled",
158+
"RYUK_DOCKER_SOCKET": "ryuk_docker_socket",
159+
"RYUK_IMAGE": "ryuk_image",
160+
"RYUK_PRIVILEGED": "ryuk_privileged",
161+
"RYUK_RECONNECTION_TIMEOUT": "ryuk_reconnection_timeout",
162+
"SLEEP_TIME": "sleep_time",
163+
"TIMEOUT": "timeout",
164+
}
165+
)
166+
167+
168+
def __dir__() -> list[str]:
169+
return __all__ + list(_deprecated_attribute_mapping.keys())
170+
171+
172+
def __getattr__(name: str) -> object:
173+
"""
174+
Allow getting deprecated legacy settings.
175+
"""
176+
module = f"{__name__!r}"
177+
178+
if name in _deprecated_attribute_mapping:
179+
attrib = _deprecated_attribute_mapping[name]
180+
warnings.warn(
181+
f"{module}.{name} is deprecated. Use {module}.testcontainers_config.{attrib} instead.",
182+
DeprecationWarning,
183+
stacklevel=2,
184+
)
185+
return getattr(testcontainers_config, attrib)
186+
raise AttributeError(f"module {module} has no attribute {name!r}")

core/testcontainers/core/waiting_utils.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ def wait_for(condition: Callable[..., bool]) -> bool:
8383
def wait_for_logs(
8484
container: "DockerContainer",
8585
predicate: Union[Callable, str],
86-
timeout: float = config.timeout,
86+
timeout: Union[float, None] = None,
8787
interval: float = 1,
8888
predicate_streams_and: bool = False,
8989
raise_on_exit: bool = False,
@@ -104,6 +104,8 @@ def wait_for_logs(
104104
Returns:
105105
duration: Number of seconds until the predicate was satisfied.
106106
"""
107+
if timeout is None:
108+
timeout = config.timeout
107109
if isinstance(predicate, str):
108110
predicate = re.compile(predicate, re.MULTILINE).search
109111
wrapped = container.get_wrapped_container()

core/tests/test_config.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,3 +146,23 @@ def test_get_docker_host_root(monkeypatch: pytest.MonkeyPatch) -> None:
146146
# Define a Root like Docker Client
147147
monkeypatch.setenv("DOCKER_HOST", "unix://")
148148
assert get_docker_socket() == "/var/run/docker.sock"
149+
150+
151+
def test_deprecated_settings() -> None:
152+
"""
153+
Getting deprecated settings raises a DepcrationWarning
154+
"""
155+
from testcontainers.core import config
156+
157+
with pytest.warns(DeprecationWarning):
158+
assert config.TIMEOUT
159+
160+
161+
def test_attribut_error() -> None:
162+
"""
163+
Accessing a not existing attribute raises an AttributeError
164+
"""
165+
from testcontainers.core import config
166+
167+
with pytest.raises(AttributeError):
168+
config.missing

core/tests/test_labels.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
TESTCONTAINERS_NAMESPACE,
88
)
99
import pytest
10-
from testcontainers.core.config import RYUK_IMAGE
10+
from testcontainers.core.config import testcontainers_config as config
1111

1212

1313
def assert_in_with_value(labels: dict[str, str], label: str, value: str, known_before_test_time: bool):
@@ -43,7 +43,7 @@ def test_containers_respect_custom_labels_if_no_collision():
4343

4444

4545
def test_if_ryuk_no_session():
46-
actual_labels = create_labels(RYUK_IMAGE, None)
46+
actual_labels = create_labels(config.ryuk_image, None)
4747
assert LABEL_SESSION_ID not in actual_labels
4848

4949

modules/scylla/testcontainers/scylla/__init__.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
from testcontainers.core.config import MAX_TRIES
21
from testcontainers.core.generic import DockerContainer
32
from testcontainers.core.waiting_utils import wait_container_is_ready, wait_for_logs
43

@@ -29,7 +28,7 @@ def __init__(self, image="scylladb/scylla:latest", ports_to_expose=(9042,)):
2928

3029
@wait_container_is_ready(OSError)
3130
def _connect(self):
32-
wait_for_logs(self, predicate="Starting listening for CQL clients", timeout=MAX_TRIES)
31+
wait_for_logs(self, predicate="Starting listening for CQL clients")
3332
cluster = self.get_cluster()
3433
cluster.connect()
3534

0 commit comments

Comments
 (0)