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 f3aa337e5..19ce80c88 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,28 +33,27 @@ 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() 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()) - 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) """ @@ -131,19 +133,54 @@ 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: + if not self._ryuk_docker_socket: + self.ryuk_docker_socket = get_docker_socket() + return self._ryuk_docker_socket -testcontainers_config = TestcontainersConfiguration() + @ryuk_docker_socket.setter + def ryuk_docker_socket(self, value: str) -> None: + self._ryuk_docker_socket = value + + +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..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 = config.timeout, + timeout: Union[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_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 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()