Skip to content

Commit 6166684

Browse files
authored
Merge branch 'main' into support_ssh2
2 parents 1a35b47 + 0e0bb24 commit 6166684

File tree

7 files changed

+272
-6
lines changed

7 files changed

+272
-6
lines changed

core/README.rst

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ Testcontainers Core
1616

1717
.. autoclass:: testcontainers.core.wait_strategies.WaitStrategy
1818

19+
.. autoclass:: testcontainers.core.transferable.Transferable
20+
1921
.. raw:: html
2022

2123
<hr>
@@ -49,3 +51,20 @@ Using `DockerContainer` and `DockerImage` to create a container:
4951

5052
The `DockerImage` class is used to build the image from the specified path and tag.
5153
The `DockerContainer` class is then used to create a container from the image.
54+
55+
Copying a file from disk into a container:
56+
57+
.. doctest::
58+
59+
>>> import tempfile
60+
>>> from pathlib import Path
61+
>>> from testcontainers.core.container import DockerContainer
62+
63+
>>> with tempfile.TemporaryDirectory() as tmp:
64+
... my_file = Path(tmp) / "my_file.txt"
65+
... _ = my_file.write_text("file content")
66+
... with DockerContainer("bash", command="sleep infinity") as container:
67+
... container.copy_into_container(my_file, "/tmp/my_file.txt")
68+
... result = container.exec("cat /tmp/my_file.txt")
69+
... result.output
70+
b'file content'

core/testcontainers/core/container.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import contextlib
2+
import io
3+
import pathlib
24
import sys
5+
import tarfile
36
from os import PathLike
47
from socket import socket
58
from types import TracebackType
@@ -18,6 +21,7 @@
1821
from testcontainers.core.exceptions import ContainerConnectException, ContainerStartException
1922
from testcontainers.core.labels import LABEL_SESSION_ID, SESSION_ID
2023
from testcontainers.core.network import Network
24+
from testcontainers.core.transferable import Transferable, TransferSpec, build_transfer_tar
2125
from testcontainers.core.utils import is_arm, setup_logger
2226
from testcontainers.core.wait_strategies import LogMessageWaitStrategy
2327
from testcontainers.core.waiting_utils import WaitStrategy
@@ -69,6 +73,7 @@ def __init__(
6973
network: Optional[Network] = None,
7074
network_aliases: Optional[list[str]] = None,
7175
_wait_strategy: Optional[WaitStrategy] = None,
76+
transferables: Optional[list[TransferSpec]] = None,
7277
**kwargs: Any,
7378
) -> None:
7479
self.env = env or {}
@@ -100,6 +105,11 @@ def __init__(
100105
self._kwargs = kwargs
101106
self._wait_strategy: Optional[WaitStrategy] = _wait_strategy
102107

108+
self._transferable_specs: list[TransferSpec] = []
109+
if transferables:
110+
for t in transferables:
111+
self.with_copy_into_container(*t)
112+
103113
def with_env(self, key: str, value: str) -> Self:
104114
self.env[key] = value
105115
return self
@@ -208,6 +218,10 @@ def start(self) -> Self:
208218
self._wait_strategy.wait_until_ready(self)
209219

210220
logger.info("Container started: %s", self._container.short_id)
221+
222+
for t in self._transferable_specs:
223+
self._transfer_into_container(*t)
224+
211225
return self
212226

213227
def stop(self, force: bool = True, delete_volume: bool = True) -> None:
@@ -275,7 +289,7 @@ def with_volume_mapping(self, host: Union[str, PathLike[str]], container: str, m
275289

276290
def with_tmpfs_mount(self, container_path: str, size: Optional[str] = None) -> Self:
277291
"""Mount a tmpfs volume on the container.
278-
292+
279293
:param container_path: Container path to mount tmpfs on (e.g., '/data')
280294
:param size: Optional size limit (e.g., '256m', '1g'). If None, unbounded.
281295
:return: Self for chaining
@@ -318,6 +332,35 @@ def _configure(self) -> None:
318332
# placeholder if subclasses want to define this and use the default start method
319333
pass
320334

335+
def with_copy_into_container(
336+
self, transferable: Transferable, destination_in_container: str, mode: int = 0o644
337+
) -> Self:
338+
self._transferable_specs.append((transferable, destination_in_container, mode))
339+
return self
340+
341+
def copy_into_container(self, transferable: Transferable, destination_in_container: str, mode: int = 0o644) -> None:
342+
return self._transfer_into_container(transferable, destination_in_container, mode)
343+
344+
def _transfer_into_container(self, transferable: Transferable, destination_in_container: str, mode: int) -> None:
345+
if not self._container:
346+
raise ContainerStartException("Container must be started before transferring files")
347+
348+
data = build_transfer_tar(transferable, destination_in_container, mode)
349+
if not self._container.put_archive(path="/", data=data):
350+
raise OSError(f"Failed to put archive into container at {destination_in_container}")
351+
352+
def copy_from_container(self, source_in_container: str, destination_on_host: pathlib.Path) -> None:
353+
if not self._container:
354+
raise ContainerStartException("Container must be started before copying files")
355+
356+
tar_stream, _ = self._container.get_archive(source_in_container)
357+
358+
with tarfile.open(fileobj=io.BytesIO(b"".join(tar_stream))) as tar:
359+
for member in tar.getmembers():
360+
extracted = tar.extractfile(member)
361+
if extracted is not None:
362+
destination_on_host.write_bytes(extracted.read())
363+
321364

322365
class Reaper:
323366
"""
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import io
2+
import pathlib
3+
import tarfile
4+
from typing import Union
5+
6+
Transferable = Union[bytes, pathlib.Path]
7+
8+
TransferSpec = Union[tuple[Transferable, str], tuple[Transferable, str, int]]
9+
10+
11+
def build_transfer_tar(transferable: Transferable, destination: str, mode: int = 0o644) -> bytes:
12+
"""Build a tar archive containing the transferable, ready for put_archive(path="/")."""
13+
buf = io.BytesIO()
14+
with tarfile.open(fileobj=buf, mode="w") as tar:
15+
if isinstance(transferable, bytes):
16+
info = tarfile.TarInfo(name=destination)
17+
info.size = len(transferable)
18+
info.mode = mode
19+
tar.addfile(info, io.BytesIO(transferable))
20+
elif isinstance(transferable, pathlib.Path):
21+
if transferable.is_file():
22+
info = tarfile.TarInfo(name=destination)
23+
info.size = transferable.stat().st_size
24+
info.mode = mode
25+
with transferable.open("rb") as f:
26+
tar.addfile(info, f)
27+
elif transferable.is_dir():
28+
tar.add(str(transferable), arcname=f"{destination.rstrip('/')}/{transferable.name}")
29+
else:
30+
raise TypeError(f"Path {transferable} is neither a file nor directory")
31+
else:
32+
raise TypeError("source must be bytes or Path")
33+
return buf.getvalue()

core/tests/test_transferable.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
from pathlib import Path
2+
3+
import pytest
4+
from testcontainers.core.container import DockerContainer
5+
from testcontainers.core.transferable import Transferable, TransferSpec, build_transfer_tar
6+
7+
import io
8+
import tarfile
9+
from typing import Any
10+
11+
12+
def test_build_transfer_tar_from_bytes():
13+
data = b"hello world"
14+
tar_bytes = build_transfer_tar(data, "/tmp/my_file")
15+
16+
with tarfile.open(fileobj=io.BytesIO(tar_bytes)) as tar:
17+
members = tar.getmembers()
18+
assert len(members) == 1
19+
assert members[0].name == "/tmp/my_file"
20+
assert members[0].size == len(data)
21+
assert members[0].mode == 0o644
22+
extracted = tar.extractfile(members[0])
23+
assert extracted is not None
24+
assert extracted.read() == data
25+
26+
27+
def test_build_transfer_tar_from_file(tmp_path: Path):
28+
my_file = tmp_path / "my_file"
29+
my_file.write_bytes(b"file content")
30+
31+
tar_bytes = build_transfer_tar(my_file, "/dest/my_file", mode=0o755)
32+
33+
with tarfile.open(fileobj=io.BytesIO(tar_bytes)) as tar:
34+
members = tar.getmembers()
35+
assert len(members) == 1
36+
assert members[0].name == "/dest/my_file"
37+
assert members[0].mode == 0o755
38+
extracted = tar.extractfile(members[0])
39+
assert extracted is not None
40+
assert extracted.read() == b"file content"
41+
42+
43+
def test_build_transfer_tar_from_directory(tmp_path: Path):
44+
source_dir = tmp_path / "my_dir"
45+
source_dir.mkdir()
46+
(source_dir / "a.txt").write_bytes(b"aaa")
47+
48+
tar_bytes = build_transfer_tar(source_dir, "/dest")
49+
50+
with tarfile.open(fileobj=io.BytesIO(tar_bytes)) as tar:
51+
names = tar.getnames()
52+
assert any("my_dir" in n for n in names)
53+
assert any("a.txt" in n for n in names)
54+
55+
56+
def test_build_transfer_tar_rejects_invalid_type():
57+
with pytest.raises(TypeError, match="source must be bytes or Path"):
58+
invalid: Any = 123
59+
build_transfer_tar(invalid, "/tmp/bad")
60+
61+
62+
def test_build_transfer_tar_rejects_nonexistent_path(tmp_path: Path):
63+
bad_path = tmp_path / "does_not_exist"
64+
with pytest.raises(TypeError, match="neither a file nor directory"):
65+
build_transfer_tar(bad_path, "/tmp/bad")
66+
67+
68+
@pytest.fixture(name="transferable", params=(bytes, Path))
69+
def copy_sources_fixture(request, tmp_path: Path):
70+
"""
71+
Provide source argument for tests of copy_into_container
72+
"""
73+
raw_data = b"hello world"
74+
if request.param is bytes:
75+
return raw_data
76+
elif request.param is Path:
77+
my_file = tmp_path / "my_file"
78+
my_file.write_bytes(raw_data)
79+
return my_file
80+
pytest.fail("Invalid type")
81+
82+
83+
def test_copy_into_container_at_runtime(transferable: Transferable):
84+
destination_in_container = "/tmp/my_file"
85+
86+
with DockerContainer("bash", command="sleep infinity") as container:
87+
container.copy_into_container(transferable, destination_in_container)
88+
result = container.exec(f"cat {destination_in_container}")
89+
90+
assert result.exit_code == 0
91+
assert result.output == b"hello world"
92+
93+
94+
def test_copy_into_container_at_startup(transferable: Transferable):
95+
destination_in_container = "/tmp/my_file"
96+
97+
container = DockerContainer("bash", command="sleep infinity")
98+
container.with_copy_into_container(transferable, destination_in_container)
99+
100+
with container:
101+
result = container.exec(f"cat {destination_in_container}")
102+
103+
assert result.exit_code == 0
104+
assert result.output == b"hello world"
105+
106+
107+
def test_copy_into_container_via_initializer(transferable: Transferable):
108+
destination_in_container = "/tmp/my_file"
109+
transferables: list[TransferSpec] = [(transferable, destination_in_container, 0o644)]
110+
111+
with DockerContainer("bash", command="sleep infinity", transferables=transferables) as container:
112+
result = container.exec(f"cat {destination_in_container}")
113+
114+
assert result.exit_code == 0
115+
assert result.output == b"hello world"
116+
117+
118+
def test_copy_file_from_container(tmp_path: Path):
119+
file_in_container = "/tmp/foo.txt"
120+
destination_on_host = tmp_path / "foo.txt"
121+
assert not destination_on_host.is_file()
122+
123+
with DockerContainer("bash", command="sleep infinity") as container:
124+
result = container.exec(f'bash -c "echo -n hello world > {file_in_container}"')
125+
assert result.exit_code == 0
126+
container.copy_from_container(file_in_container, destination_on_host)
127+
128+
assert destination_on_host.is_file()
129+
assert destination_on_host.read_text() == "hello world"
130+
131+
132+
def test_copy_directory_into_container(tmp_path: Path):
133+
source_dir = tmp_path / "my_directory"
134+
source_dir.mkdir()
135+
my_file = source_dir / "my_file"
136+
my_file.write_bytes(b"hello world")
137+
138+
destination_in_container = "/tmp/my_destination_directory"
139+
140+
with DockerContainer("bash", command="sleep infinity") as container:
141+
container.copy_into_container(source_dir, destination_in_container)
142+
result = container.exec(f"ls {destination_in_container}")
143+
144+
assert result.exit_code == 0
145+
assert result.output == b"my_directory\n"
146+
147+
result = container.exec(f"ls {destination_in_container}/my_directory")
148+
assert result.exit_code == 0
149+
assert result.output == b"my_file\n"
150+
151+
result = container.exec(f"cat {destination_in_container}/my_directory/my_file")
152+
assert result.exit_code == 0
153+
assert result.output == b"hello world"

modules/keycloak/testcontainers/keycloak/__init__.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,12 +78,22 @@ def _configure(self) -> None:
7878
def get_url(self) -> str:
7979
host = self.get_container_host_ip()
8080
port = self.get_exposed_port(self.port)
81-
return f"http://{host}:{port}"
81+
82+
if "KC_HTTP_RELATIVE_PATH" in self.env:
83+
path = self.env.get("KC_HTTP_RELATIVE_PATH", "").strip("/")
84+
return f"http://{host}:{port}/{path}/"
85+
else:
86+
return f"http://{host}:{port}"
8287

8388
def get_management_url(self) -> str:
8489
host = self.get_container_host_ip()
8590
port = self.get_exposed_port(self.management_port)
86-
return f"http://{host}:{port}"
91+
92+
if "KC_HTTP_MANAGEMENT_RELATIVE_PATH" in self.env:
93+
path = self.env.get("KC_HTTP_MANAGEMENT_RELATIVE_PATH", "").strip("/")
94+
return f"http://{host}:{port}/{path}/"
95+
else:
96+
return f"http://{host}:{port}"
8797

8898
@wait_container_is_ready(requests.exceptions.ConnectionError, requests.exceptions.ReadTimeout)
8999
def _readiness_probe(self) -> None:

modules/keycloak/tests/test_keycloak.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,14 @@
22
from testcontainers.keycloak import KeycloakContainer
33

44

5-
@pytest.mark.parametrize("image_version", ["26.0.0", "25.0", "24.0.1", "18.0"])
5+
@pytest.mark.parametrize("image_version", ["26.4.0", "26.0.0", "25.0", "24.0.1", "18.0"])
66
def test_docker_run_keycloak(image_version: str):
77
with KeycloakContainer(f"quay.io/keycloak/keycloak:{image_version}") as keycloak_admin:
88
assert keycloak_admin.get_client().users_count() == 1
9+
10+
11+
def test_docker_run_keycloak_with_management_relative_path():
12+
with KeycloakContainer("quay.io/keycloak/keycloak:26.4.0").with_env(
13+
"KC_HTTP_MANAGEMENT_RELATIVE_PATH", "/some/deeply/nested/path"
14+
) as keycloak_admin:
15+
assert keycloak_admin.get_client().users_count() == 1

modules/sftp/testcontainers/sftp/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from cryptography.hazmat.primitives.asymmetric import rsa
2121

2222
from testcontainers.core.container import DockerContainer
23-
from testcontainers.core.waiting_utils import wait_for_logs
23+
from testcontainers.core.wait_strategies import LogMessageWaitStrategy
2424

2525
if TYPE_CHECKING:
2626
from typing_extensions import Self
@@ -265,8 +265,9 @@ def _configure(self) -> None:
265265
self.with_exposed_ports(self.port)
266266

267267
def start(self) -> Self:
268+
strategy = LogMessageWaitStrategy(f".*Server listening on 0.0.0.0 port {self.port}.*")
269+
self.waiting_for(strategy)
268270
super().start()
269-
wait_for_logs(self, f".*Server listening on 0.0.0.0 port {self.port}.*")
270271
return self
271272

272273
def get_exposed_sftp_port(self) -> int:

0 commit comments

Comments
 (0)