Skip to content

Commit 4a33d81

Browse files
authored
Merge branch 'main' into feat/clickhouse-wait-strategy
2 parents a38a758 + 87332c1 commit 4a33d81

File tree

12 files changed

+289
-31
lines changed

12 files changed

+289
-31
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"

docs/features/wait_strategies.md

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,17 @@ Testcontainers-Python provides several strategies to wait for containers to be r
44

55
## Basic Wait Strategy
66

7-
The simplest way to wait for a container is using the `wait_container_is_ready` decorator:
7+
The simplest way to wait for a container is using a structured wait strategy:
88

99
```python
10-
from testcontainers.core.waiting_utils import wait_container_is_ready
10+
from testcontainers.core.wait_strategies import HttpWaitStrategy
1111

1212
class MyContainer(DockerContainer):
13-
@wait_container_is_ready()
1413
def _connect(self):
15-
# Your connection logic here
16-
pass
14+
HttpWaitStrategy(8080).wait_until_ready(self)
1715
```
1816

19-
This decorator will retry the method until it succeeds or times out. By default, it will retry for 120 seconds with a 1-second interval between attempts.
17+
The strategy will retry until it succeeds or times out. By default, it will retry for 120 seconds with a 1-second interval between attempts.
2018

2119
## Log-based Waiting
2220

modules/generic/testcontainers/generic/server.py

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
from typing import Union
2-
from urllib.error import HTTPError, URLError
3-
from urllib.request import urlopen
42

53
import httpx
64

75
from testcontainers.core.container import DockerContainer
86
from testcontainers.core.exceptions import ContainerStartException
97
from testcontainers.core.image import DockerImage
10-
from testcontainers.core.waiting_utils import wait_container_is_ready
8+
from testcontainers.core.wait_strategies import HttpWaitStrategy
119

1210

1311
class ServerContainer(DockerContainer):
@@ -40,19 +38,9 @@ def __init__(self, port: int, image: Union[str, DockerImage]) -> None:
4038
self.internal_port = port
4139
self.with_exposed_ports(self.internal_port)
4240

43-
@wait_container_is_ready(HTTPError, URLError)
4441
def _connect(self) -> None:
45-
# noinspection HttpUrlsUsage
46-
url = self._create_connection_url()
47-
try:
48-
with urlopen(url) as r:
49-
assert b"" in r.read()
50-
except HTTPError as e:
51-
# 404 is expected, as the server may not have the specific endpoint we are looking for
52-
if e.code == 404:
53-
pass
54-
else:
55-
raise
42+
strategy = HttpWaitStrategy(self.internal_port).for_status_code(404)
43+
strategy.wait_until_ready(self)
5644

5745
def get_api_url(self) -> str:
5846
raise NotImplementedError

0 commit comments

Comments
 (0)