Skip to content

Commit 6301a0c

Browse files
feat: Transferable (#852)
In #676 for copying to/from `DockerContainer`, it was suggested that we should clarify the interface & usage a bit before proceeding. This PR aims to push that conversation forward with some test cases illustrating proposed usages. Although here I created a `Transferable` object, in most cases there's no need for the caller to explicitly construct a Transferable, just pass the `bytes | Path` **Proposed use cases:** 1. Copy into a container by passing a `TransferSpec` into the initializer: ``` DockerContainer(... transferrables=[TransferSpec(source, destination_in_container), ...) ``` 2. Copy into the container via the builder pattern, prior to starting the container: ``` DockerContainer(...) .with_copy_into_container(b"some_bytes", destination_in_container) .with_copy_into_container(some_path, destination_in_container) ``` 3. Copy into the container at runtime: ``` with DockerContainer(...) as container: container.copy_into_container(b"some_bytes", destination_in_container) container.copy_into_container(some_path, destination_in_container) ``` --------- Co-authored-by: Roy Moore <roy@moore.co.il>
1 parent 2ca2321 commit 6301a0c

File tree

4 files changed

+192
-0
lines changed

4 files changed

+192
-0
lines changed

core/README.rst

Lines changed: 2 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>

core/testcontainers/core/container.py

Lines changed: 76 additions & 0 deletions
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
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:
@@ -318,6 +332,68 @@ 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 isinstance(transferable, bytes):
346+
self._transfer_file_content_into_container(transferable, destination_in_container, mode)
347+
elif isinstance(transferable, pathlib.Path):
348+
if transferable.is_file():
349+
self._transfer_file_content_into_container(transferable.read_bytes(), destination_in_container, mode)
350+
elif transferable.is_dir():
351+
self._transfer_directory_into_container(transferable, destination_in_container, mode)
352+
else:
353+
raise TypeError(f"Path {transferable} is neither a file nor directory")
354+
else:
355+
raise TypeError("source must be bytes or PathLike")
356+
357+
def _transfer_file_content_into_container(
358+
self, file_content: bytes, destination_in_container: str, mode: int
359+
) -> None:
360+
fileobj = io.BytesIO()
361+
with tarfile.open(fileobj=fileobj, mode="w") as tar:
362+
tarinfo = tarfile.TarInfo(name=destination_in_container)
363+
tarinfo.size = len(file_content)
364+
tarinfo.mode = mode
365+
tar.addfile(tarinfo, io.BytesIO(file_content))
366+
fileobj.seek(0)
367+
assert self._container is not None
368+
rv = self._container.put_archive(path="/", data=fileobj.getvalue())
369+
assert rv is True
370+
371+
def _transfer_directory_into_container(
372+
self, source_directory: pathlib.Path, destination_in_container: str, mode: int
373+
) -> None:
374+
assert self._container is not None
375+
result = self._container.exec_run(["mkdir", "-p", destination_in_container])
376+
assert result.exit_code == 0
377+
378+
fileobj = io.BytesIO()
379+
with tarfile.open(fileobj=fileobj, mode="w") as tar:
380+
tar.add(source_directory, arcname=source_directory.name)
381+
fileobj.seek(0)
382+
rv = self._container.put_archive(path=destination_in_container, data=fileobj.getvalue())
383+
assert rv is True
384+
385+
def copy_from_container(self, source_in_container: str, destination_on_host: pathlib.Path) -> None:
386+
assert self._container is not None
387+
tar_stream, _ = self._container.get_archive(source_in_container)
388+
389+
for chunk in tar_stream:
390+
with tarfile.open(fileobj=io.BytesIO(chunk)) as tar:
391+
for member in tar.getmembers():
392+
with open(destination_on_host, "wb") as f:
393+
fileobj = tar.extractfile(member)
394+
assert fileobj is not None
395+
f.write(fileobj.read())
396+
321397

322398
class Reaper:
323399
"""
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import pathlib
2+
from typing import Union
3+
4+
Transferable = Union[bytes, pathlib.Path]
5+
6+
TransferSpec = Union[tuple[Transferable, str], tuple[Transferable, str, int]]

core/tests/test_core.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import tempfile
22
from pathlib import Path
33

4+
import pytest
45
from testcontainers.core.container import DockerContainer
6+
from testcontainers.core.transferable import Transferable, TransferSpec
57

68

79
def test_garbage_collection_is_defensive():
@@ -46,3 +48,109 @@ def test_docker_container_with_env_file():
4648
assert "ADMIN_EMAIL=admin@example.org" in output
4749
assert "ROOT_URL=example.org/app" in output
4850
print(output)
51+
52+
53+
@pytest.fixture(name="transferable", params=(bytes, Path))
54+
def copy_sources_fixture(request, tmp_path: Path):
55+
"""
56+
Provide source argument for tests of copy_into_container
57+
"""
58+
raw_data = b"hello world"
59+
if request.param is bytes:
60+
return raw_data
61+
elif request.param is Path:
62+
my_file = tmp_path / "my_file"
63+
my_file.write_bytes(raw_data)
64+
return my_file
65+
pytest.fail("Invalid type")
66+
67+
68+
def test_copy_into_container_at_runtime(transferable: Transferable):
69+
# Given
70+
destination_in_container = "/tmp/my_file"
71+
72+
with DockerContainer("bash", command="sleep infinity") as container:
73+
# When
74+
container.copy_into_container(transferable, destination_in_container)
75+
result = container.exec(f"cat {destination_in_container}")
76+
77+
# Then
78+
assert result.exit_code == 0
79+
assert result.output == b"hello world"
80+
81+
82+
def test_copy_into_container_at_startup(transferable: Transferable):
83+
# Given
84+
destination_in_container = "/tmp/my_file"
85+
86+
container = DockerContainer("bash", command="sleep infinity")
87+
container.with_copy_into_container(transferable, destination_in_container)
88+
89+
with container:
90+
# When
91+
result = container.exec(f"cat {destination_in_container}")
92+
93+
# Then
94+
assert result.exit_code == 0
95+
assert result.output == b"hello world"
96+
97+
98+
def test_copy_into_container_via_initializer(transferable: Transferable):
99+
# Given
100+
destination_in_container = "/tmp/my_file"
101+
transferables: list[TransferSpec] = [(transferable, destination_in_container, 0o644)]
102+
103+
with DockerContainer("bash", command="sleep infinity", transferables=transferables) as container:
104+
# When
105+
result = container.exec(f"cat {destination_in_container}")
106+
107+
# Then
108+
assert result.exit_code == 0
109+
assert result.output == b"hello world"
110+
111+
112+
def test_copy_file_from_container(tmp_path: Path):
113+
# Given
114+
file_in_container = "/tmp/foo.txt"
115+
destination_on_host = tmp_path / "foo.txt"
116+
assert not destination_on_host.is_file()
117+
118+
with DockerContainer("bash", command="sleep infinity") as container:
119+
result = container.exec(f'bash -c "echo -n hello world > {file_in_container}"')
120+
assert result.exit_code == 0
121+
122+
# When
123+
container.copy_from_container(file_in_container, destination_on_host)
124+
125+
# Then
126+
assert destination_on_host.is_file()
127+
assert destination_on_host.read_text() == "hello world"
128+
129+
130+
def test_copy_directory_into_container(tmp_path: Path):
131+
# Given
132+
source_dir = tmp_path / "my_directory"
133+
source_dir.mkdir()
134+
my_file = source_dir / "my_file"
135+
my_file.write_bytes(b"hello world")
136+
137+
destination_in_container = "/tmp/my_destination_directory"
138+
139+
with DockerContainer("bash", command="sleep infinity") as container:
140+
# When
141+
container.copy_into_container(source_dir, destination_in_container)
142+
result = container.exec(f"ls {destination_in_container}")
143+
144+
# Then - my_directory exists
145+
assert result.exit_code == 0
146+
assert result.output == b"my_directory\n"
147+
148+
# Then - my_file is in directory
149+
result = container.exec(f"ls {destination_in_container}/my_directory")
150+
assert result.exit_code == 0
151+
assert result.output == b"my_file\n"
152+
153+
# Then - my_file contents are correct
154+
result = container.exec(f"cat {destination_in_container}/my_directory/my_file")
155+
assert result.exit_code == 0
156+
assert result.output == b"hello world"

0 commit comments

Comments
 (0)