Skip to content

feat: make config monkeypatchable, fix config related startup issues #833

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,20 @@ 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 |
| `TESTCONTAINERS_RYUK_PRIVILEGED` | `false` | Run ryuk as a privileged container |
| `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"
```
3 changes: 3 additions & 0 deletions core/testcontainers/core/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .config import testcontainers_config

__all__ = ["testcontainers_config"]
99 changes: 68 additions & 31 deletions core/testcontainers/core/config.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -30,28 +33,27 @@

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
Expand Down Expand Up @@ -94,16 +96,16 @@

@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)

"""
Expand Down Expand Up @@ -131,19 +133,54 @@
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())

Check warning on line 169 in core/testcontainers/core/config.py

View check run for this annotation

Codecov / codecov/patch

core/testcontainers/core/config.py#L169

Added line #L169 was not covered by tests


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}")
4 changes: 3 additions & 1 deletion core/testcontainers/core/waiting_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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()
Expand Down
20 changes: 20 additions & 0 deletions core/tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions core/tests/test_labels.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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


Expand Down
3 changes: 1 addition & 2 deletions modules/scylla/testcontainers/scylla/__init__.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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()

Expand Down
Loading