Skip to content

Commit 0c70979

Browse files
authored
Merge branch 'main' into typed_docker_client
2 parents ea6aa92 + e9e40f9 commit 0c70979

File tree

15 files changed

+585
-44
lines changed

15 files changed

+585
-44
lines changed

.github/.release-please-manifest.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
".": "4.9.2"
2+
".": "4.10.0"
33
}

.readthedocs.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,6 @@ build:
1414
# https://github.com/readthedocs/readthedocs.org/issues/4912#issuecomment-1143587902s
1515
jobs:
1616
post_install:
17-
- pip install poetry==1.7.1 # match version from poetry.lock
17+
- pip install poetry==2.1.2 # match version from poetry.lock
1818
- poetry config virtualenvs.create false
1919
- poetry install --all-extras

CHANGELOG.md

+13
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
# Changelog
22

3+
## [4.10.0](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.9.2...testcontainers-v4.10.0) (2025-04-02)
4+
5+
6+
### Features
7+
8+
* Add SocatContainer ([#795](https://github.com/testcontainers/testcontainers-python/issues/795)) ([2f9139c](https://github.com/testcontainers/testcontainers-python/commit/2f9139ca3ea9fba36325373b63635a5f539a3003))
9+
10+
11+
### Bug Fixes
12+
13+
* **ollama:** make device request a list ([#799](https://github.com/testcontainers/testcontainers-python/issues/799)) ([9497a45](https://github.com/testcontainers/testcontainers-python/commit/9497a45c39d13761aa3dd30dd5605676cbbe4b46))
14+
* **security:** Update track-modules job ([#787](https://github.com/testcontainers/testcontainers-python/issues/787)) ([f979525](https://github.com/testcontainers/testcontainers-python/commit/f97952505eba089f9cbbc979f8091dafbf520669))
15+
316
## [4.9.2](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.9.1...testcontainers-v4.9.2) (2025-02-26)
417

518

core/testcontainers/compose/compose.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ class DockerCompose:
139139
The list of services to use from this DockerCompose.
140140
client_args:
141141
arguments to pass to docker.from_env()
142+
docker_command_path:
143+
The docker compose command to run.
142144
143145
Example:
144146
@@ -195,7 +197,7 @@ def docker_compose_command(self) -> list[str]:
195197

196198
@cached_property
197199
def compose_command_property(self) -> list[str]:
198-
docker_compose_cmd = [self.docker_command_path or "docker", "compose"]
200+
docker_compose_cmd = [self.docker_command_path] if self.docker_command_path else ["docker", "compose"]
199201
if self.compose_file_name:
200202
for file in self.compose_file_name:
201203
docker_compose_cmd += ["-f", file]

core/testcontainers/core/config.py

+22-2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
from pathlib import Path
77
from typing import Optional, Union
88

9+
import docker
10+
911

1012
class ConnectionMode(Enum):
1113
bridge_ip = "bridge_ip"
@@ -24,14 +26,32 @@ def use_mapped_port(self) -> bool:
2426
return True
2527

2628

29+
def get_docker_socket() -> str:
30+
"""
31+
Determine the docker socket, prefer value given by env variable
32+
33+
Using the docker api ensure we handle rootless docker properly
34+
"""
35+
if socket_path := environ.get("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE"):
36+
return socket_path
37+
38+
client = docker.from_env()
39+
try:
40+
socket_path = client.api.get_adapter(client.api.base_url).socket_path
41+
# return the normalized path as string
42+
return str(Path(socket_path).absolute())
43+
except AttributeError:
44+
return "/var/run/docker.sock"
45+
46+
2747
MAX_TRIES = int(environ.get("TC_MAX_TRIES", 120))
2848
SLEEP_TIME = int(environ.get("TC_POOLING_INTERVAL", 1))
2949
TIMEOUT = MAX_TRIES * SLEEP_TIME
3050

3151
RYUK_IMAGE: str = environ.get("RYUK_CONTAINER_IMAGE", "testcontainers/ryuk:0.8.1")
3252
RYUK_PRIVILEGED: bool = environ.get("TESTCONTAINERS_RYUK_PRIVILEGED", "false") == "true"
3353
RYUK_DISABLED: bool = environ.get("TESTCONTAINERS_RYUK_DISABLED", "false") == "true"
34-
RYUK_DOCKER_SOCKET: str = environ.get("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE", "/var/run/docker.sock")
54+
RYUK_DOCKER_SOCKET: str = get_docker_socket()
3555
RYUK_RECONNECTION_TIMEOUT: str = environ.get("RYUK_RECONNECTION_TIMEOUT", "10s")
3656
TC_HOST_OVERRIDE: Optional[str] = environ.get("TC_HOST", environ.get("TESTCONTAINERS_HOST_OVERRIDE"))
3757

@@ -86,7 +106,7 @@ class TestcontainersConfiguration:
86106
tc_properties: dict[str, str] = field(default_factory=read_tc_properties)
87107
_docker_auth_config: Optional[str] = field(default_factory=lambda: environ.get("DOCKER_AUTH_CONFIG"))
88108
tc_host_override: Optional[str] = TC_HOST_OVERRIDE
89-
connection_mode_override: Optional[ConnectionMode] = None
109+
connection_mode_override: Optional[ConnectionMode] = field(default_factory=get_user_overwritten_connection_mode)
90110

91111
"""
92112
https://github.com/testcontainers/testcontainers-go/blob/dd76d1e39c654433a3d80429690d07abcec04424/docker.go#L644

core/testcontainers/core/container.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ def get_exposed_port(self, port: int) -> int:
155155
return self.get_docker_client().port(self._container.id, port)
156156
return port
157157

158-
def with_command(self, command: str) -> Self:
158+
def with_command(self, command: Union[str, list[str]]) -> Self:
159159
self._command = command
160160
return self
161161

core/testcontainers/core/image.py

+6-3
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,13 @@ class DockerImage:
2323
>>> with DockerImage(path="./core/tests/image_fixtures/sample/", tag="test-image") as image:
2424
... logs = image.get_logs()
2525
26-
:param tag: Tag for the image to be built (default: None)
2726
:param path: Path to the build context
27+
:param docker_client_kw: Keyword arguments to pass to the DockerClient
28+
:param tag: Tag for the image to be built (default: None)
29+
:param clean_up: Remove the image after exiting the context (default: True)
2830
:param dockerfile_path: Path to the Dockerfile within the build context path (default: Dockerfile)
2931
:param no_cache: Bypass build cache; CLI's --no-cache
32+
:param kwargs: Additional keyword arguments to pass to the underlying docker-py
3033
"""
3134

3235
def __init__(
@@ -49,11 +52,11 @@ def __init__(
4952
self._dockerfile_path = dockerfile_path
5053
self._no_cache = no_cache
5154

52-
def build(self, **kwargs) -> Self:
55+
def build(self) -> Self:
5356
logger.info(f"Building image from {self.path}")
5457
docker_client = self.get_docker_client()
5558
self._image, self._logs = docker_client.build(
56-
path=str(self.path), tag=self.tag, dockerfile=self._dockerfile_path, nocache=self._no_cache, **kwargs
59+
path=str(self.path), tag=self.tag, dockerfile=self._dockerfile_path, nocache=self._no_cache, **self._kwargs
5760
)
5861
logger.info(f"Built image {self.short_id} with tag {self.tag}")
5962
return self

core/testcontainers/socat/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# flake8: noqa
2+
from testcontainers.socat.socat import SocatContainer

core/testcontainers/socat/socat.py

+88
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
#
2+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
3+
# not use this file except in compliance with the License. You may obtain
4+
# a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
10+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
11+
# License for the specific language governing permissions and limitations
12+
# under the License.
13+
import random
14+
import socket
15+
import string
16+
from typing import Optional
17+
18+
from testcontainers.core.container import DockerContainer
19+
from testcontainers.core.waiting_utils import wait_container_is_ready
20+
21+
22+
class SocatContainer(DockerContainer):
23+
"""
24+
A container that uses socat to forward TCP connections.
25+
"""
26+
27+
def __init__(
28+
self,
29+
image: str = "alpine/socat:1.7.4.3-r0",
30+
**kwargs,
31+
) -> None:
32+
"""
33+
Initialize a new SocatContainer with the given image.
34+
35+
Args:
36+
image: The Docker image to use. Defaults to "alpine/socat:1.7.4.3-r0".
37+
**kwargs: Additional keyword arguments to pass to the DockerContainer constructor.
38+
"""
39+
# Dictionary to store targets (port -> host:port mappings)
40+
self.targets: dict[int, str] = {}
41+
42+
kwargs["entrypoint"] = "/bin/sh"
43+
44+
random_suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=8))
45+
self.with_name(f"testcontainers-socat-{random_suffix}")
46+
47+
super().__init__(image=image, **kwargs)
48+
49+
def with_target(self, exposed_port: int, host: str, internal_port: Optional[int] = None) -> "SocatContainer":
50+
"""
51+
Add a target to forward connections from the exposed port to the given host and port.
52+
53+
Args:
54+
exposed_port: The port to expose on the container.
55+
host: The host to forward connections to.
56+
internal_port: The port on the host to forward connections to. Defaults to the exposed_port if not provided.
57+
58+
Returns:
59+
Self: The container instance for chaining.
60+
"""
61+
if internal_port is None:
62+
internal_port = exposed_port
63+
64+
self.with_exposed_ports(exposed_port)
65+
self.targets[exposed_port] = f"{host}:{internal_port}"
66+
return self
67+
68+
def _configure(self) -> None:
69+
if not self.targets:
70+
return
71+
72+
socat_commands = []
73+
for port, target in self.targets.items():
74+
socat_commands.append(f"socat TCP-LISTEN:{port},fork,reuseaddr TCP:{target}")
75+
76+
command = " & ".join(socat_commands)
77+
78+
self.with_command(f'-c "{command}"')
79+
80+
def start(self) -> "SocatContainer":
81+
super().start()
82+
self._connect()
83+
return self
84+
85+
@wait_container_is_ready(OSError)
86+
def _connect(self) -> None:
87+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
88+
s.connect((self.get_container_host_ip(), int(self.get_exposed_port(next(iter(self.ports))))))

core/tests/test_config.py

+62
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,17 @@
33
from testcontainers.core.config import (
44
TestcontainersConfiguration as TCC,
55
TC_FILE,
6+
TestcontainersConfiguration,
67
get_user_overwritten_connection_mode,
78
ConnectionMode,
9+
get_docker_socket,
810
)
911

1012
from pytest import MonkeyPatch, mark, LogCaptureFixture
1113

1214
import logging
1315
import tempfile
16+
from unittest.mock import Mock
1417

1518

1619
def test_read_tc_properties(monkeypatch: MonkeyPatch) -> None:
@@ -79,8 +82,67 @@ def test_invalid_connection_mode(monkeypatch: pytest.MonkeyPatch) -> None:
7982
def test_valid_connection_mode(monkeypatch: pytest.MonkeyPatch, mode: str, use_mapped: bool) -> None:
8083
monkeypatch.setenv("TESTCONTAINERS_CONNECTION_MODE", mode)
8184
assert get_user_overwritten_connection_mode().use_mapped_port is use_mapped
85+
assert TestcontainersConfiguration().connection_mode_override.use_mapped_port is use_mapped
8286

8387

8488
def test_no_connection_mode_given(monkeypatch: pytest.MonkeyPatch) -> None:
8589
monkeypatch.delenv("TESTCONTAINERS_CONNECTION_MODE", raising=False)
8690
assert get_user_overwritten_connection_mode() is None
91+
92+
93+
def test_get_docker_socket_uses_env(monkeypatch: pytest.MonkeyPatch) -> None:
94+
"""
95+
If TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE env var is given prefer it
96+
"""
97+
monkeypatch.setenv("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE", "/var/test.socket")
98+
assert get_docker_socket() == "/var/test.socket"
99+
100+
101+
@pytest.fixture
102+
def mock_docker_client_connections(monkeypatch: pytest.MonkeyPatch) -> None:
103+
"""
104+
Ensure the docker client does not make any actual network calls
105+
"""
106+
from docker.transport.sshconn import SSHHTTPAdapter
107+
from docker.api.client import APIClient
108+
109+
# ensure that no actual connection is tried
110+
monkeypatch.setattr(SSHHTTPAdapter, "_connect", Mock())
111+
monkeypatch.setattr(SSHHTTPAdapter, "_create_paramiko_client", Mock())
112+
monkeypatch.setattr(APIClient, "_retrieve_server_version", Mock(return_value="1.47"))
113+
114+
115+
@pytest.mark.usefixtures("mock_docker_client_connections")
116+
def test_get_docker_host_default(monkeypatch: pytest.MonkeyPatch) -> None:
117+
"""
118+
If non socket docker-host is given return default
119+
120+
Still ryuk will properly still not work but this is the historical default
121+
122+
"""
123+
monkeypatch.delenv("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE", raising=False)
124+
# Define Fake SSH Docker client
125+
monkeypatch.setenv("DOCKER_HOST", "ssh://remote_host")
126+
assert get_docker_socket() == "/var/run/docker.sock"
127+
128+
129+
@pytest.mark.usefixtures("mock_docker_client_connections")
130+
def test_get_docker_host_non_root(monkeypatch: pytest.MonkeyPatch) -> None:
131+
"""
132+
Use the socket determined by the Docker API Adapter
133+
"""
134+
monkeypatch.delenv("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE", raising=False)
135+
# Define a Non-Root like Docker Client
136+
monkeypatch.setenv("DOCKER_HOST", "unix://var/run/user/1000/docker.sock")
137+
assert get_docker_socket() == "/var/run/user/1000/docker.sock"
138+
139+
140+
@pytest.mark.usefixtures("mock_docker_client_connections")
141+
def test_get_docker_host_root(monkeypatch: pytest.MonkeyPatch) -> None:
142+
"""
143+
Use the socket determined by the Docker API Adapter
144+
"""
145+
monkeypatch.delenv("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE", raising=False)
146+
# Define a Root like Docker Client
147+
monkeypatch.setenv("DOCKER_HOST", "unix://")
148+
assert get_docker_socket() == "/var/run/docker.sock"

core/tests/test_image.py

+21
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,24 @@ def test_docker_image_with_custom_dockerfile_path(dockerfile_path: Optional[Path
6464
with DockerContainer(str(image)) as container:
6565
assert container._container.image.short_id.endswith(image_short_id), "Image ID mismatch"
6666
assert container.get_logs() == (("Hello world!\n").encode(), b""), "Container logs mismatch"
67+
68+
69+
def test_docker_image_with_kwargs():
70+
with tempfile.TemporaryDirectory() as temp_directory:
71+
with open(f"{temp_directory}/Dockerfile", "w") as f:
72+
f.write(
73+
f"""
74+
FROM alpine:latest
75+
ARG TEST_ARG
76+
ENV TEST_ARG $TEST_ARG
77+
CMD echo $TEST_ARG
78+
"""
79+
)
80+
with DockerImage(
81+
path=temp_directory, tag="test", clean_up=True, no_cache=True, buildargs={"TEST_ARG": "new_arg"}
82+
) as image:
83+
image_short_id = image.short_id
84+
assert image.get_wrapped_image() is not None
85+
with DockerContainer(str(image)) as container:
86+
assert container._container.image.short_id.endswith(image_short_id), "Image ID mismatch"
87+
assert container.get_logs() == (("new_arg\n").encode(), b""), "Container logs mismatch"

core/tests/test_socat.py

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import httpx
2+
import pytest
3+
from testcontainers.core.container import DockerContainer
4+
from testcontainers.core.network import Network
5+
from testcontainers.socat.socat import SocatContainer
6+
7+
8+
def test_socat_with_helloworld():
9+
with (
10+
Network() as network,
11+
DockerContainer("testcontainers/helloworld:1.2.0")
12+
.with_exposed_ports(8080)
13+
.with_network(network)
14+
.with_network_aliases("helloworld"),
15+
SocatContainer().with_network(network).with_target(8080, "helloworld") as socat,
16+
):
17+
socat_url = f"http://{socat.get_container_host_ip()}:{socat.get_exposed_port(8080)}"
18+
19+
response = httpx.get(f"{socat_url}/ping")
20+
21+
assert response.status_code == 200
22+
assert response.content == b"PONG"

modules/ollama/testcontainers/ollama/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ def __init__(
101101
def _check_and_add_gpu_capabilities(self):
102102
info = self.get_docker_client().client.info()
103103
if "nvidia" in info["Runtimes"]:
104-
self._kwargs = {**self._kwargs, "device_requests": DeviceRequest(count=-1, capabilities=[["gpu"]])}
104+
self._kwargs = {**self._kwargs, "device_requests": [DeviceRequest(count=-1, capabilities=[["gpu"]])]}
105105

106106
def start(self) -> "OllamaContainer":
107107
"""

0 commit comments

Comments
 (0)