Skip to content

Commit 0e0bb24

Browse files
authored
fix(core): Refactor copy file (#996)
Follow-up to #852, cleaning up the copy file feature after it landed. (Related to #676)
1 parent 898faf6 commit 0e0bb24

File tree

6 files changed

+215
-157
lines changed

6 files changed

+215
-157
lines changed

core/README.rst

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,20 @@ Using `DockerContainer` and `DockerImage` to create a container:
5151

5252
The `DockerImage` class is used to build the image from the specified path and tag.
5353
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: 15 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from testcontainers.core.exceptions import ContainerConnectException, ContainerStartException
2222
from testcontainers.core.labels import LABEL_SESSION_ID, SESSION_ID
2323
from testcontainers.core.network import Network
24-
from testcontainers.core.transferable import Transferable, TransferSpec
24+
from testcontainers.core.transferable import Transferable, TransferSpec, build_transfer_tar
2525
from testcontainers.core.utils import is_arm, setup_logger
2626
from testcontainers.core.wait_strategies import LogMessageWaitStrategy
2727
from testcontainers.core.waiting_utils import WaitStrategy
@@ -289,7 +289,7 @@ def with_volume_mapping(self, host: Union[str, PathLike[str]], container: str, m
289289

290290
def with_tmpfs_mount(self, container_path: str, size: Optional[str] = None) -> Self:
291291
"""Mount a tmpfs volume on the container.
292-
292+
293293
:param container_path: Container path to mount tmpfs on (e.g., '/data')
294294
:param size: Optional size limit (e.g., '256m', '1g'). If None, unbounded.
295295
:return: Self for chaining
@@ -342,57 +342,24 @@ def copy_into_container(self, transferable: Transferable, destination_in_contain
342342
return self._transfer_into_container(transferable, destination_in_container, mode)
343343

344344
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
345+
if not self._container:
346+
raise ContainerStartException("Container must be started before transferring files")
377347

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
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}")
384351

385352
def copy_from_container(self, source_in_container: str, destination_on_host: pathlib.Path) -> None:
386-
assert self._container is not None
353+
if not self._container:
354+
raise ContainerStartException("Container must be started before copying files")
355+
387356
tar_stream, _ = self._container.get_archive(source_in_container)
388357

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())
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())
396363

397364

398365
class Reaper:
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,33 @@
1+
import io
12
import pathlib
3+
import tarfile
24
from typing import Union
35

46
Transferable = Union[bytes, pathlib.Path]
57

68
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_core.py

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

4-
import pytest
54
from testcontainers.core.container import DockerContainer
6-
from testcontainers.core.transferable import Transferable, TransferSpec
75

86

97
def test_garbage_collection_is_defensive():
@@ -48,109 +46,3 @@ def test_docker_container_with_env_file():
4846
assert "ADMIN_EMAIL=admin@example.org" in output
4947
assert "ROOT_URL=example.org/app" in output
5048
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)