Skip to content

Commit 0615c29

Browse files
Merge branch 'main' into reusable_containers
2 parents 2113561 + e9e40f9 commit 0615c29

File tree

21 files changed

+619
-59
lines changed

21 files changed

+619
-59
lines changed
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
".": "4.9.1"
2+
".": "4.10.0"
33
}

.github/workflows/ci-community.yml

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,20 +19,20 @@ jobs:
1919
- name: Checkout contents
2020
uses: actions/checkout@v4
2121
with:
22-
fetch-depth: 0 # recommended by tj-actions/changed-files
22+
fetch-depth: 0
2323
- name: Get changed files
2424
id: changed-files
25-
uses: tj-actions/changed-files@v42
25+
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3
2626
with:
27-
path: "./modules"
28-
diff_relative: true
29-
dir_names: true
30-
dir_names_exclude_current_dir: true
31-
json: true
27+
base: ${{ github.ref }}
28+
list-files: 'json'
29+
filters: |
30+
modules:
31+
- 'modules/**'
3232
- name: Compute modules from files
3333
id: compute-changes
3434
run: |
35-
modules=$(echo "${{ steps.changed-files.outputs.all_changed_files }}" | jq '.[] | split("/") | first' | jq -s -c '. | unique')
35+
modules=$(echo "${{ toJson(steps.changed-files.outputs.modules_files) }}" | jq '.[] | split("/") | nth(1)' | jq -s -c '. | unique')
3636
echo "computed_modules=$modules"
3737
echo "computed_modules=$modules" >> $GITHUB_OUTPUT
3838
outputs:

.readthedocs.yml

Lines changed: 1 addition & 1 deletion
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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,34 @@
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+
16+
## [4.9.2](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.9.1...testcontainers-v4.9.2) (2025-02-26)
17+
18+
19+
### Bug Fixes
20+
21+
* Change env var disabling OpenSearch security plugin ([#773](https://github.com/testcontainers/testcontainers-python/issues/773)) ([2620d7f](https://github.com/testcontainers/testcontainers-python/commit/2620d7fb1157caa18c3bef4bf2f9b3b79cd2f075))
22+
* **core:** create_label test ([#771](https://github.com/testcontainers/testcontainers-python/issues/771)) ([7517297](https://github.com/testcontainers/testcontainers-python/commit/751729722a013b46f67c09b4318b1b3d92b98008))
23+
* **core:** multiple container start invocations with custom labels ([#769](https://github.com/testcontainers/testcontainers-python/issues/769)) ([3e783a8](https://github.com/testcontainers/testcontainers-python/commit/3e783a80aa11b9c87201404a895d922624f0d451))
24+
* **keycloak:** Fixed Keycloak testcontainer for latest version v26.1.0 ([#766](https://github.com/testcontainers/testcontainers-python/issues/766)) ([b1642e9](https://github.com/testcontainers/testcontainers-python/commit/b1642e98c4d349564c4365782d1b58c9810b719a))
25+
* **scylla:** scylla get cluster method ([#778](https://github.com/testcontainers/testcontainers-python/issues/778)) ([46913c1](https://github.com/testcontainers/testcontainers-python/commit/46913c18a8b6f37bf8dc193828148926b6fc56a8))
26+
27+
28+
### Documentation
29+
30+
* Fixed typo in CONTRIBUTING.md ([#767](https://github.com/testcontainers/testcontainers-python/issues/767)) ([f0bb0f5](https://github.com/testcontainers/testcontainers-python/commit/f0bb0f54bea83885698bd137e24c397498709362))
31+
332
## [4.9.1](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.9.0...testcontainers-v4.9.1) (2025-01-21)
433

534

core/testcontainers/compose/compose.py

Lines changed: 3 additions & 1 deletion
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

Lines changed: 22 additions & 2 deletions
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

@@ -89,7 +109,7 @@ class TestcontainersConfiguration:
89109
tc_properties: dict[str, str] = field(default_factory=read_tc_properties)
90110
_docker_auth_config: Optional[str] = field(default_factory=lambda: environ.get("DOCKER_AUTH_CONFIG"))
91111
tc_host_override: Optional[str] = TC_HOST_OVERRIDE
92-
connection_mode_override: Optional[ConnectionMode] = None
112+
connection_mode_override: Optional[ConnectionMode] = field(default_factory=get_user_overwritten_connection_mode)
93113

94114
"""
95115
https://github.com/testcontainers/testcontainers-go/blob/dd76d1e39c654433a3d80429690d07abcec04424/docker.go#L644

core/testcontainers/core/container.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ def get_exposed_port(self, port: int) -> int:
206206
return self.get_docker_client().port(self._container.id, port)
207207
return port
208208

209-
def with_command(self, command: str) -> Self:
209+
def with_command(self, command: Union[str, list[str]]) -> Self:
210210
self._command = command
211211
return self
212212

core/testcontainers/core/image.py

Lines changed: 6 additions & 3 deletions
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
Lines changed: 2 additions & 0 deletions
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

Lines changed: 88 additions & 0 deletions
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))))))

0 commit comments

Comments
 (0)