From a4d9abcc8ae47beb9e4c229243f31731249d7942 Mon Sep 17 00:00:00 2001 From: David Ankin Date: Mon, 16 Jun 2025 12:39:47 -0400 Subject: [PATCH 1/4] fix: do not connect to docker on startup --- core/testcontainers/core/config.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/core/testcontainers/core/config.py b/core/testcontainers/core/config.py index f3aa337e5..1eec3487f 100644 --- a/core/testcontainers/core/config.py +++ b/core/testcontainers/core/config.py @@ -49,7 +49,7 @@ def get_docker_socket() -> str: RYUK_IMAGE: str = environ.get("RYUK_CONTAINER_IMAGE", "testcontainers/ryuk:0.8.1") RYUK_PRIVILEGED: bool = environ.get("TESTCONTAINERS_RYUK_PRIVILEGED", "false") == "true" RYUK_DISABLED: bool = environ.get("TESTCONTAINERS_RYUK_DISABLED", "false") == "true" -RYUK_DOCKER_SOCKET: str = get_docker_socket() +RYUK_DOCKER_SOCKET: str = "" # get_docker_socket() RYUK_RECONNECTION_TIMEOUT: str = environ.get("RYUK_RECONNECTION_TIMEOUT", "10s") TC_HOST_OVERRIDE: Optional[str] = environ.get("TC_HOST", environ.get("TESTCONTAINERS_HOST_OVERRIDE")) @@ -99,7 +99,7 @@ class TestcontainersConfiguration: ryuk_image: str = RYUK_IMAGE ryuk_privileged: bool = RYUK_PRIVILEGED ryuk_disabled: bool = RYUK_DISABLED - ryuk_docker_socket: str = RYUK_DOCKER_SOCKET + # ryuk_docker_socket: str = RYUK_DOCKER_SOCKET ryuk_reconnection_timeout: str = RYUK_RECONNECTION_TIMEOUT tc_properties: dict[str, str] = field(default_factory=read_tc_properties) _docker_auth_config: Optional[str] = field(default_factory=lambda: environ.get("DOCKER_AUTH_CONFIG")) @@ -131,6 +131,10 @@ def tc_properties_get_tc_host(self) -> Union[str, None]: def timeout(self) -> int: return self.max_tries * self.sleep_time + @property + def ryuk_docker_socket(self) -> str: + return get_docker_socket() + testcontainers_config = TestcontainersConfiguration() From 563ab731e2bec79aebd32bdae9faa69382367ddf Mon Sep 17 00:00:00 2001 From: Carli* Freudenberg Date: Tue, 17 Jun 2025 11:31:16 +0200 Subject: [PATCH 2/4] feature: make all configuration runtime changeable --- README.md | 10 ++ core/testcontainers/core/__init__.py | 3 + core/testcontainers/core/config.py | 95 +++++++++++++------ core/testcontainers/core/waiting_utils.py | 4 +- core/tests/test_labels.py | 4 +- .../scylla/testcontainers/scylla/__init__.py | 3 +- 6 files changed, 83 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 43d5d2aa6..8dfff2cb5 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ See [CONTRIBUTING.md](.github/CONTRIBUTING.md) for more details. ## Configuration +You can set environment variables to configure the library behaviour: + | Env Variable | Example | Description | | --------------------------------------- | --------------------------- | ---------------------------------------------------------------------------------- | | `TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE` | `/var/run/docker.sock` | Path to Docker's socket used by ryuk | @@ -48,3 +50,11 @@ See [CONTRIBUTING.md](.github/CONTRIBUTING.md) for more details. | `TESTCONTAINERS_RYUK_DISABLED` | `false` | Disable ryuk | | `RYUK_CONTAINER_IMAGE` | `testcontainers/ryuk:0.8.1` | Custom image for ryuk | | `RYUK_RECONNECTION_TIMEOUT` | `10s` | Reconnection timeout for Ryuk TCP socket before Ryuk reaps all dangling containers | + +Alternatively you can set the configuration during runtime: + +```python +from testcontainers.core import testcontainers_config + +testcontainers_config.ryuk_docker_socket = "/home/user/docker.sock" +``` diff --git a/core/testcontainers/core/__init__.py b/core/testcontainers/core/__init__.py index e69de29bb..fdae0086b 100644 --- a/core/testcontainers/core/__init__.py +++ b/core/testcontainers/core/__init__.py @@ -0,0 +1,3 @@ +from .config import testcontainers_config + +__all__ = ["testcontainers_config"] diff --git a/core/testcontainers/core/config.py b/core/testcontainers/core/config.py index 1eec3487f..d06350313 100644 --- a/core/testcontainers/core/config.py +++ b/core/testcontainers/core/config.py @@ -1,10 +1,13 @@ +import types +import warnings +from collections.abc import Mapping from dataclasses import dataclass, field from enum import Enum from logging import warning from os import environ from os.path import exists from pathlib import Path -from typing import Optional, Union +from typing import Final, Optional, Union import docker @@ -30,7 +33,7 @@ def get_docker_socket() -> str: Using the docker api ensure we handle rootless docker properly """ - if socket_path := environ.get("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE"): + if socket_path := environ.get("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE", ""): return socket_path client = docker.from_env() @@ -38,20 +41,19 @@ def get_docker_socket() -> str: socket_path = client.api.get_adapter(client.api.base_url).socket_path # return the normalized path as string return str(Path(socket_path).absolute()) - except AttributeError: + except Exception: return "/var/run/docker.sock" -MAX_TRIES = int(environ.get("TC_MAX_TRIES", 120)) -SLEEP_TIME = int(environ.get("TC_POOLING_INTERVAL", 1)) -TIMEOUT = MAX_TRIES * SLEEP_TIME +def get_bool_env(name: str) -> bool: + """ + Get environment variable named `name` and convert it to bool. + + Defaults to False. + """ + value = environ.get(name, "") + return value.lower() in ("yes", "true", "t", "y", "1") -RYUK_IMAGE: str = environ.get("RYUK_CONTAINER_IMAGE", "testcontainers/ryuk:0.8.1") -RYUK_PRIVILEGED: bool = environ.get("TESTCONTAINERS_RYUK_PRIVILEGED", "false") == "true" -RYUK_DISABLED: bool = environ.get("TESTCONTAINERS_RYUK_DISABLED", "false") == "true" -RYUK_DOCKER_SOCKET: str = "" # get_docker_socket() -RYUK_RECONNECTION_TIMEOUT: str = environ.get("RYUK_RECONNECTION_TIMEOUT", "10s") -TC_HOST_OVERRIDE: Optional[str] = environ.get("TC_HOST", environ.get("TESTCONTAINERS_HOST_OVERRIDE")) TC_FILE = ".testcontainers.properties" TC_GLOBAL = Path.home() / TC_FILE @@ -94,16 +96,16 @@ def read_tc_properties() -> dict[str, str]: @dataclass class TestcontainersConfiguration: - max_tries: int = MAX_TRIES - sleep_time: int = SLEEP_TIME - ryuk_image: str = RYUK_IMAGE - ryuk_privileged: bool = RYUK_PRIVILEGED - ryuk_disabled: bool = RYUK_DISABLED - # ryuk_docker_socket: str = RYUK_DOCKER_SOCKET - ryuk_reconnection_timeout: str = RYUK_RECONNECTION_TIMEOUT + max_tries: int = int(environ.get("TC_MAX_TRIES", "120")) + sleep_time: int = int(environ.get("TC_POOLING_INTERVAL", "1")) + ryuk_image: str = environ.get("RYUK_CONTAINER_IMAGE", "testcontainers/ryuk:0.8.1") + ryuk_privileged: bool = get_bool_env("TESTCONTAINERS_RYUK_PRIVILEGED") + ryuk_disabled: bool = get_bool_env("TESTCONTAINERS_RYUK_DISABLED") + _ryuk_docker_socket: str = "" + ryuk_reconnection_timeout: str = environ.get("RYUK_RECONNECTION_TIMEOUT", "10s") tc_properties: dict[str, str] = field(default_factory=read_tc_properties) _docker_auth_config: Optional[str] = field(default_factory=lambda: environ.get("DOCKER_AUTH_CONFIG")) - tc_host_override: Optional[str] = TC_HOST_OVERRIDE + tc_host_override: Optional[str] = environ.get("TC_HOST", environ.get("TESTCONTAINERS_HOST_OVERRIDE")) connection_mode_override: Optional[ConnectionMode] = field(default_factory=get_user_overwritten_connection_mode) """ @@ -133,21 +135,52 @@ def timeout(self) -> int: @property def ryuk_docker_socket(self) -> str: - return get_docker_socket() + if not self._ryuk_docker_socket: + self.ryuk_docker_socket = get_docker_socket() + return self._ryuk_docker_socket + @ryuk_docker_socket.setter + def ryuk_docker_socket(self, value: str) -> None: + self._ryuk_docker_socket = value -testcontainers_config = TestcontainersConfiguration() + +testcontainers_config: Final = TestcontainersConfiguration() __all__ = [ - # Legacy things that are deprecated: - "MAX_TRIES", - "RYUK_DISABLED", - "RYUK_DOCKER_SOCKET", - "RYUK_IMAGE", - "RYUK_PRIVILEGED", - "RYUK_RECONNECTION_TIMEOUT", - "SLEEP_TIME", - "TIMEOUT", # Public API of this module: "testcontainers_config", ] + +_deprecated_attribute_mapping: Final[Mapping[str, str]] = types.MappingProxyType( + { + "MAX_TRIES": "max_tries", + "RYUK_DISABLED": "ryuk_disabled", + "RYUK_DOCKER_SOCKET": "ryuk_docker_socket", + "RYUK_IMAGE": "ryuk_image", + "RYUK_PRIVILEGED": "ryuk_privileged", + "RYUK_RECONNECTION_TIMEOUT": "ryuk_reconnection_timeout", + "SLEEP_TIME": "sleep_time", + "TIMEOUT": "timeout", + } +) + + +def __dir__() -> list[str]: + return __all__ + list(_deprecated_attribute_mapping.keys()) + + +def __getattr__(name: str) -> object: + """ + Allow getting deprecated legacy settings. + """ + module = f"{__name__!r}" + + if name in _deprecated_attribute_mapping: + attrib = _deprecated_attribute_mapping[name] + warnings.warn( + f"{module}.{name} is deprecated. Use {module}.testcontainers_config.{attrib} instead.", + DeprecationWarning, + stacklevel=2, + ) + return getattr(testcontainers_config, attrib) + raise AttributeError(f"module {module} has no attribute {name!r}") diff --git a/core/testcontainers/core/waiting_utils.py b/core/testcontainers/core/waiting_utils.py index 0d531b151..ad29c0905 100644 --- a/core/testcontainers/core/waiting_utils.py +++ b/core/testcontainers/core/waiting_utils.py @@ -83,7 +83,7 @@ def wait_for(condition: Callable[..., bool]) -> bool: def wait_for_logs( container: "DockerContainer", predicate: Union[Callable, str], - timeout: float = config.timeout, + timeout: float | None = None, interval: float = 1, predicate_streams_and: bool = False, raise_on_exit: bool = False, @@ -104,6 +104,8 @@ def wait_for_logs( Returns: duration: Number of seconds until the predicate was satisfied. """ + if timeout is None: + timeout = config.timeout if isinstance(predicate, str): predicate = re.compile(predicate, re.MULTILINE).search wrapped = container.get_wrapped_container() diff --git a/core/tests/test_labels.py b/core/tests/test_labels.py index b920b08fe..c34baaeef 100644 --- a/core/tests/test_labels.py +++ b/core/tests/test_labels.py @@ -7,7 +7,7 @@ TESTCONTAINERS_NAMESPACE, ) import pytest -from testcontainers.core.config import RYUK_IMAGE +from testcontainers.core.config import testcontainers_config as config 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(): def test_if_ryuk_no_session(): - actual_labels = create_labels(RYUK_IMAGE, None) + actual_labels = create_labels(config.ryuk_image, None) assert LABEL_SESSION_ID not in actual_labels diff --git a/modules/scylla/testcontainers/scylla/__init__.py b/modules/scylla/testcontainers/scylla/__init__.py index 9ff941765..6d79ec165 100644 --- a/modules/scylla/testcontainers/scylla/__init__.py +++ b/modules/scylla/testcontainers/scylla/__init__.py @@ -1,4 +1,3 @@ -from testcontainers.core.config import MAX_TRIES from testcontainers.core.generic import DockerContainer from testcontainers.core.waiting_utils import wait_container_is_ready, wait_for_logs @@ -29,7 +28,7 @@ def __init__(self, image="scylladb/scylla:latest", ports_to_expose=(9042,)): @wait_container_is_ready(OSError) def _connect(self): - wait_for_logs(self, predicate="Starting listening for CQL clients", timeout=MAX_TRIES) + wait_for_logs(self, predicate="Starting listening for CQL clients") cluster = self.get_cluster() cluster.connect() From 159cb435dbb13edd5521e728959bd825786bf83e Mon Sep 17 00:00:00 2001 From: Carli* Freudenberg Date: Tue, 17 Jun 2025 11:43:04 +0200 Subject: [PATCH 3/4] feature: make all configuration runtime changeable --- core/testcontainers/core/waiting_utils.py | 2 +- core/tests/test_config.py | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/core/testcontainers/core/waiting_utils.py b/core/testcontainers/core/waiting_utils.py index ad29c0905..36e6a812f 100644 --- a/core/testcontainers/core/waiting_utils.py +++ b/core/testcontainers/core/waiting_utils.py @@ -83,7 +83,7 @@ def wait_for(condition: Callable[..., bool]) -> bool: def wait_for_logs( container: "DockerContainer", predicate: Union[Callable, str], - timeout: float | None = None, + timeout: Union[float, None] = None, interval: float = 1, predicate_streams_and: bool = False, raise_on_exit: bool = False, diff --git a/core/tests/test_config.py b/core/tests/test_config.py index 845ca7ac5..eccc186b6 100644 --- a/core/tests/test_config.py +++ b/core/tests/test_config.py @@ -146,3 +146,23 @@ def test_get_docker_host_root(monkeypatch: pytest.MonkeyPatch) -> None: # Define a Root like Docker Client monkeypatch.setenv("DOCKER_HOST", "unix://") assert get_docker_socket() == "/var/run/docker.sock" + + +def test_deprecated_settings() -> None: + """ + Getting deprecated settings raises a DepcrationWarning + """ + from testcontainers.core import config + + with pytest.warns(DeprecationWarning): + assert config.TIMEOUT + + +def test_attribut_error() -> None: + """ + Accessing a not existing attribute raises an AttributeError + """ + from testcontainers.core import config + + with pytest.raises(AttributeError): + config.missing From 4738f325ce996ee63a058d17e42687d7a995efef Mon Sep 17 00:00:00 2001 From: David Ankin Date: Tue, 17 Jun 2025 19:09:46 -0400 Subject: [PATCH 4/4] also catch from_env --- core/testcontainers/core/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/testcontainers/core/config.py b/core/testcontainers/core/config.py index d06350313..19ce80c88 100644 --- a/core/testcontainers/core/config.py +++ b/core/testcontainers/core/config.py @@ -36,8 +36,8 @@ def get_docker_socket() -> str: if socket_path := environ.get("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE", ""): return socket_path - client = docker.from_env() try: + client = docker.from_env() socket_path = client.api.get_adapter(client.api.base_url).socket_path # return the normalized path as string return str(Path(socket_path).absolute())