From c2cda8023953bfc202b3b84a62f2667c9e63225e Mon Sep 17 00:00:00 2001 From: Ryan Hoban Date: Mon, 28 Jul 2025 19:16:39 +0000 Subject: [PATCH 1/6] Tests demonstrating copy file interfaces --- core/testcontainers/core/container.py | 54 ++++++++++++ core/tests/test_core.py | 120 +++++++++++++++++++++++++- 2 files changed, 173 insertions(+), 1 deletion(-) diff --git a/core/testcontainers/core/container.py b/core/testcontainers/core/container.py index e0456fa03..81a1b2ca4 100644 --- a/core/testcontainers/core/container.py +++ b/core/testcontainers/core/container.py @@ -1,4 +1,8 @@ import contextlib +import dataclasses +import io +import pathlib +import tarfile from os import PathLike from socket import socket from types import TracebackType @@ -31,6 +35,13 @@ class Mount(TypedDict): mode: str +@dataclasses.dataclass +class Transferrable: + source: Union[bytes, PathLike] + destination_in_container: str + mode: int = 0o644 + + class DockerContainer: """ Basic container object to spin up Docker instances. @@ -69,6 +80,7 @@ def __init__( volumes: Optional[list[tuple[str, str, str]]] = None, network: Optional[Network] = None, network_aliases: Optional[list[str]] = None, + transferrables: Optional[tuple[Transferrable]] = None, **kwargs: Any, ) -> None: self.env = env or {} @@ -96,6 +108,7 @@ def __init__( self.with_network_aliases(*network_aliases) self._kwargs = kwargs + self._transferrables = transferrables or [] def with_env(self, key: str, value: str) -> Self: self.env[key] = value @@ -196,6 +209,10 @@ def start(self) -> Self: ) logger.info("Container started: %s", self._container.short_id) + + for t in self._transferrables: + self._transfer_into_container(t.source, t.destination_in_container, t.mode) + return self def stop(self, force: bool = True, delete_volume: bool = True) -> None: @@ -273,6 +290,43 @@ def _configure(self) -> None: # placeholder if subclasses want to define this and use the default start method pass + def with_copy_into_container( + self, file_content: bytes | PathLike, destination_in_container: str, mode: int = 0o644 + ): + self._transferrables.append(Transferrable(file_content, destination_in_container, mode)) + return self + + def copy_into_container(self, file_content: bytes, destination_in_container: str, mode: int = 0o644): + return self._transfer_into_container(file_content, destination_in_container, mode) + + def _transfer_into_container(self, source: bytes | PathLike, destination_in_container: str, mode: int): + if isinstance(source, bytes): + file_content = source + elif isinstance(source, PathLike): + p = pathlib.Path(source) + file_content = p.read_bytes() + else: + raise TypeError("source must be bytes or PathLike") + + fileobj = io.BytesIO() + with tarfile.open(fileobj=fileobj, mode="w") as tar: + tarinfo = tarfile.TarInfo(name=destination_in_container) + tarinfo.size = len(file_content) + tarinfo.mode = mode + tar.addfile(tarinfo, io.BytesIO(file_content)) + fileobj.seek(0) + rv = self._container.put_archive(path="/", data=fileobj.getvalue()) + assert rv is True + + def copy_from_container(self, source_in_container: str, destination_on_host: PathLike): + tar_stream, _ = self._container.get_archive(source_in_container) + + for chunk in tar_stream: + with tarfile.open(fileobj=io.BytesIO(chunk)) as tar: + for member in tar.getmembers(): + with open(destination_on_host, "wb") as f: + f.write(tar.extractfile(member).read()) + class Reaper: """ diff --git a/core/tests/test_core.py b/core/tests/test_core.py index 9312b0bca..4e887c36a 100644 --- a/core/tests/test_core.py +++ b/core/tests/test_core.py @@ -1,7 +1,7 @@ import tempfile from pathlib import Path -from testcontainers.core.container import DockerContainer +from testcontainers.core.container import DockerContainer, Transferrable def test_garbage_collection_is_defensive(): @@ -46,3 +46,121 @@ def test_docker_container_with_env_file(): assert "ADMIN_EMAIL=admin@example.org" in output assert "ROOT_URL=example.org/app" in output print(output) + + +def test_copy_file_into_container_at_runtime(tmp_path: Path): + # Given + my_file = tmp_path / "my_file" + my_file.write_text("hello world") + destination_in_container = "/tmp/my_file" + + with DockerContainer("bash", command="sleep infinity") as container: + # When + container.copy_into_container(my_file, destination_in_container) + result = container.exec(f"cat {destination_in_container}") + + # Then + assert result.exit_code == 0 + assert result.output == b"hello world" + + +def test_copy_file_into_container_at_startup(tmp_path: Path): + # Given + my_file = tmp_path / "my_file" + my_file.write_text("hello world") + destination_in_container = "/tmp/my_file" + + container = DockerContainer("bash", command="sleep infinity") + container.with_copy_into_container(my_file, destination_in_container) + + with container: + # When + result = container.exec(f"cat {destination_in_container}") + + # Then + assert result.exit_code == 0 + assert result.output == b"hello world" + + +def test_copy_file_into_container_via_initializer(tmp_path: Path): + # Given + my_file = tmp_path / "my_file" + my_file.write_text("hello world") + destination_in_container = "/tmp/my_file" + + with DockerContainer( + "bash", command="sleep infinity", transferrables=(Transferrable(my_file, destination_in_container),) + ) as container: + # When + result = container.exec(f"cat {destination_in_container}") + + # Then + assert result.exit_code == 0 + assert result.output == b"hello world" + + +def test_copy_bytes_to_container_at_runtime(): + # Given + file_content = b"hello world" + destination_in_container = "/tmp/my_file" + + with DockerContainer("bash", command="sleep infinity") as container: + # When + container.copy_into_container(file_content, destination_in_container) + + # Then + result = container.exec(f"cat {destination_in_container}") + + assert result.exit_code == 0 + assert result.output == b"hello world" + + +def test_copy_bytes_to_container_at_startup(): + # Given + file_content = b"hello world" + destination_in_container = "/tmp/my_file" + + container = DockerContainer("bash", command="sleep infinity") + container.with_copy_into_container(file_content, destination_in_container) + + with container: + # When + result = container.exec(f"cat {destination_in_container}") + + # Then + assert result.exit_code == 0 + assert result.output == b"hello world" + + +def test_copy_bytes_to_container_via_initializer(): + # Given + file_content = b"hello world" + destination_in_container = "/tmp/my_file" + + with DockerContainer( + "bash", command="sleep infinity", transferrables=(Transferrable(file_content, destination_in_container),) + ) as container: + # When + result = container.exec(f"cat {destination_in_container}") + + # Then + assert result.exit_code == 0 + assert result.output == b"hello world" + + +def test_copy_file_from_container(tmp_path: Path): + # Given + file_in_container = "/tmp/foo.txt" + destination_on_host = tmp_path / "foo.txt" + assert not destination_on_host.is_file() + + with DockerContainer("bash", command="sleep infinity") as container: + result = container.exec(f'bash -c "echo -n hello world > {file_in_container}"') + assert result.exit_code == 0 + + # When + container.copy_from_container(file_in_container, destination_on_host) + + # Then + assert destination_on_host.is_file() + assert destination_on_host.read_text() == "hello world" From 631740eee2c98f0aacfeaa5d6a830c32c850329b Mon Sep 17 00:00:00 2001 From: Ryan Hoban Date: Mon, 28 Jul 2025 20:48:41 +0000 Subject: [PATCH 2/6] spelling and split out dataclass --- core/testcontainers/core/container.py | 18 ++++++------------ core/testcontainers/core/transferable.py | 10 ++++++++++ core/tests/test_core.py | 7 ++++--- 3 files changed, 20 insertions(+), 15 deletions(-) create mode 100644 core/testcontainers/core/transferable.py diff --git a/core/testcontainers/core/container.py b/core/testcontainers/core/container.py index 81a1b2ca4..6d9915c8a 100644 --- a/core/testcontainers/core/container.py +++ b/core/testcontainers/core/container.py @@ -21,6 +21,7 @@ from testcontainers.core.exceptions import ContainerConnectException, ContainerStartException from testcontainers.core.labels import LABEL_SESSION_ID, SESSION_ID from testcontainers.core.network import Network +from testcontainers.core.transferable import Transferable from testcontainers.core.utils import is_arm, setup_logger from testcontainers.core.waiting_utils import wait_container_is_ready, wait_for_logs @@ -35,13 +36,6 @@ class Mount(TypedDict): mode: str -@dataclasses.dataclass -class Transferrable: - source: Union[bytes, PathLike] - destination_in_container: str - mode: int = 0o644 - - class DockerContainer: """ Basic container object to spin up Docker instances. @@ -80,7 +74,7 @@ def __init__( volumes: Optional[list[tuple[str, str, str]]] = None, network: Optional[Network] = None, network_aliases: Optional[list[str]] = None, - transferrables: Optional[tuple[Transferrable]] = None, + transferrables: Optional[list[Transferable]] = None, **kwargs: Any, ) -> None: self.env = env or {} @@ -108,7 +102,7 @@ def __init__( self.with_network_aliases(*network_aliases) self._kwargs = kwargs - self._transferrables = transferrables or [] + self._transferables: list[Transferable] = transferrables or [] def with_env(self, key: str, value: str) -> Self: self.env[key] = value @@ -210,7 +204,7 @@ def start(self) -> Self: logger.info("Container started: %s", self._container.short_id) - for t in self._transferrables: + for t in self._transferables: self._transfer_into_container(t.source, t.destination_in_container, t.mode) return self @@ -293,10 +287,10 @@ def _configure(self) -> None: def with_copy_into_container( self, file_content: bytes | PathLike, destination_in_container: str, mode: int = 0o644 ): - self._transferrables.append(Transferrable(file_content, destination_in_container, mode)) + self._transferables.append(Transferable(file_content, destination_in_container, mode)) return self - def copy_into_container(self, file_content: bytes, destination_in_container: str, mode: int = 0o644): + def copy_into_container(self, file_content: bytes | PathLike, destination_in_container: str, mode: int = 0o644): return self._transfer_into_container(file_content, destination_in_container, mode) def _transfer_into_container(self, source: bytes | PathLike, destination_in_container: str, mode: int): diff --git a/core/testcontainers/core/transferable.py b/core/testcontainers/core/transferable.py new file mode 100644 index 000000000..19f41109a --- /dev/null +++ b/core/testcontainers/core/transferable.py @@ -0,0 +1,10 @@ +import dataclasses +import os +from typing import Union + + +@dataclasses.dataclass +class Transferable: + source: Union[bytes, os.PathLike] + destination_in_container: str + mode: int = 0o644 diff --git a/core/tests/test_core.py b/core/tests/test_core.py index 4e887c36a..6f16a7028 100644 --- a/core/tests/test_core.py +++ b/core/tests/test_core.py @@ -1,7 +1,8 @@ import tempfile from pathlib import Path -from testcontainers.core.container import DockerContainer, Transferrable +from testcontainers.core.container import DockerContainer +from testcontainers.core.transferable import Transferable def test_garbage_collection_is_defensive(): @@ -89,7 +90,7 @@ def test_copy_file_into_container_via_initializer(tmp_path: Path): destination_in_container = "/tmp/my_file" with DockerContainer( - "bash", command="sleep infinity", transferrables=(Transferrable(my_file, destination_in_container),) + "bash", command="sleep infinity", transferrables=(Transferable(my_file, destination_in_container),) ) as container: # When result = container.exec(f"cat {destination_in_container}") @@ -138,7 +139,7 @@ def test_copy_bytes_to_container_via_initializer(): destination_in_container = "/tmp/my_file" with DockerContainer( - "bash", command="sleep infinity", transferrables=(Transferrable(file_content, destination_in_container),) + "bash", command="sleep infinity", transferrables=(Transferable(file_content, destination_in_container),) ) as container: # When result = container.exec(f"cat {destination_in_container}") From 8c29c8629fc4787ff2a57ee19bea1b9eb62867d4 Mon Sep 17 00:00:00 2001 From: Ryan Hoban Date: Mon, 28 Jul 2025 21:52:01 +0000 Subject: [PATCH 3/6] fixes --- core/README.rst | 2 ++ core/testcontainers/core/container.py | 30 ++++++++++++++---------- core/testcontainers/core/transferable.py | 8 +++++-- core/tests/test_core.py | 10 ++++---- 4 files changed, 30 insertions(+), 20 deletions(-) diff --git a/core/README.rst b/core/README.rst index 2d364d0a5..32ce157b6 100644 --- a/core/README.rst +++ b/core/README.rst @@ -14,6 +14,8 @@ Testcontainers Core .. autoclass:: testcontainers.core.generic.DbContainer +.. autoclass:: testcontainers.core.transferable.Transferable + .. raw:: html
diff --git a/core/testcontainers/core/container.py b/core/testcontainers/core/container.py index 6d9915c8a..c56b3743d 100644 --- a/core/testcontainers/core/container.py +++ b/core/testcontainers/core/container.py @@ -1,5 +1,4 @@ import contextlib -import dataclasses import io import pathlib import tarfile @@ -74,7 +73,7 @@ def __init__( volumes: Optional[list[tuple[str, str, str]]] = None, network: Optional[Network] = None, network_aliases: Optional[list[str]] = None, - transferrables: Optional[list[Transferable]] = None, + transferables: Optional[list[Transferable]] = None, **kwargs: Any, ) -> None: self.env = env or {} @@ -102,7 +101,7 @@ def __init__( self.with_network_aliases(*network_aliases) self._kwargs = kwargs - self._transferables: list[Transferable] = transferrables or [] + self._transferables: list[Transferable] = transferables or [] def with_env(self, key: str, value: str) -> Self: self.env[key] = value @@ -285,20 +284,23 @@ def _configure(self) -> None: pass def with_copy_into_container( - self, file_content: bytes | PathLike, destination_in_container: str, mode: int = 0o644 - ): + self, file_content: Union[bytes, pathlib.Path], destination_in_container: str, mode: int = 0o644 + ) -> Self: self._transferables.append(Transferable(file_content, destination_in_container, mode)) return self - def copy_into_container(self, file_content: bytes | PathLike, destination_in_container: str, mode: int = 0o644): + def copy_into_container( + self, file_content: Union[bytes, pathlib.Path], destination_in_container: str, mode: int = 0o644 + ) -> None: return self._transfer_into_container(file_content, destination_in_container, mode) - def _transfer_into_container(self, source: bytes | PathLike, destination_in_container: str, mode: int): + def _transfer_into_container( + self, source: Union[bytes, pathlib.Path], destination_in_container: str, mode: int + ) -> None: if isinstance(source, bytes): file_content = source - elif isinstance(source, PathLike): - p = pathlib.Path(source) - file_content = p.read_bytes() + elif isinstance(source, pathlib.Path): + file_content = source.read_bytes() else: raise TypeError("source must be bytes or PathLike") @@ -309,17 +311,21 @@ def _transfer_into_container(self, source: bytes | PathLike, destination_in_cont tarinfo.mode = mode tar.addfile(tarinfo, io.BytesIO(file_content)) fileobj.seek(0) + assert self._container is not None rv = self._container.put_archive(path="/", data=fileobj.getvalue()) assert rv is True - def copy_from_container(self, source_in_container: str, destination_on_host: PathLike): + def copy_from_container(self, source_in_container: str, destination_on_host: pathlib.Path) -> None: + assert self._container is not None tar_stream, _ = self._container.get_archive(source_in_container) for chunk in tar_stream: with tarfile.open(fileobj=io.BytesIO(chunk)) as tar: for member in tar.getmembers(): with open(destination_on_host, "wb") as f: - f.write(tar.extractfile(member).read()) + fileobj = tar.extractfile(member) + assert fileobj is not None + f.write(fileobj.read()) class Reaper: diff --git a/core/testcontainers/core/transferable.py b/core/testcontainers/core/transferable.py index 19f41109a..5d3f6a805 100644 --- a/core/testcontainers/core/transferable.py +++ b/core/testcontainers/core/transferable.py @@ -1,10 +1,14 @@ import dataclasses -import os +import pathlib from typing import Union @dataclasses.dataclass class Transferable: - source: Union[bytes, os.PathLike] + """ + Wrapper class enabling copying files into a container + """ + + source: Union[bytes, pathlib.Path] destination_in_container: str mode: int = 0o644 diff --git a/core/tests/test_core.py b/core/tests/test_core.py index 6f16a7028..2c03fc166 100644 --- a/core/tests/test_core.py +++ b/core/tests/test_core.py @@ -88,10 +88,9 @@ def test_copy_file_into_container_via_initializer(tmp_path: Path): my_file = tmp_path / "my_file" my_file.write_text("hello world") destination_in_container = "/tmp/my_file" + transferables = [Transferable(my_file, destination_in_container)] - with DockerContainer( - "bash", command="sleep infinity", transferrables=(Transferable(my_file, destination_in_container),) - ) as container: + with DockerContainer("bash", command="sleep infinity", transferables=transferables) as container: # When result = container.exec(f"cat {destination_in_container}") @@ -137,10 +136,9 @@ def test_copy_bytes_to_container_via_initializer(): # Given file_content = b"hello world" destination_in_container = "/tmp/my_file" + transferables = [Transferable(file_content, destination_in_container)] - with DockerContainer( - "bash", command="sleep infinity", transferrables=(Transferable(file_content, destination_in_container),) - ) as container: + with DockerContainer("bash", command="sleep infinity", transferables=transferables) as container: # When result = container.exec(f"cat {destination_in_container}") From 612e30f1eaa4f653572bdaf4522e338788ac1e1e Mon Sep 17 00:00:00 2001 From: Ryan Hoban Date: Thu, 11 Sep 2025 02:09:32 +0000 Subject: [PATCH 4/6] parametrize tests to simplify a little --- core/testcontainers/core/container.py | 1 + core/tests/test_core.py | 81 ++++++++------------------- 2 files changed, 23 insertions(+), 59 deletions(-) diff --git a/core/testcontainers/core/container.py b/core/testcontainers/core/container.py index ebe60e924..ea91e23fe 100644 --- a/core/testcontainers/core/container.py +++ b/core/testcontainers/core/container.py @@ -325,6 +325,7 @@ def _transfer_into_container( if isinstance(source, bytes): file_content = source elif isinstance(source, pathlib.Path): + assert source.is_file() # Temporary, only copying file supported file_content = source.read_bytes() else: raise TypeError("source must be bytes or PathLike") diff --git a/core/tests/test_core.py b/core/tests/test_core.py index 2c03fc166..0b92b98c1 100644 --- a/core/tests/test_core.py +++ b/core/tests/test_core.py @@ -1,6 +1,8 @@ import tempfile from pathlib import Path +from typing import Union +import pytest from testcontainers.core.container import DockerContainer from testcontainers.core.transferable import Transferable @@ -49,79 +51,41 @@ def test_docker_container_with_env_file(): print(output) -def test_copy_file_into_container_at_runtime(tmp_path: Path): - # Given - my_file = tmp_path / "my_file" - my_file.write_text("hello world") - destination_in_container = "/tmp/my_file" - - with DockerContainer("bash", command="sleep infinity") as container: - # When - container.copy_into_container(my_file, destination_in_container) - result = container.exec(f"cat {destination_in_container}") - - # Then - assert result.exit_code == 0 - assert result.output == b"hello world" - - -def test_copy_file_into_container_at_startup(tmp_path: Path): - # Given - my_file = tmp_path / "my_file" - my_file.write_text("hello world") - destination_in_container = "/tmp/my_file" - - container = DockerContainer("bash", command="sleep infinity") - container.with_copy_into_container(my_file, destination_in_container) - - with container: - # When - result = container.exec(f"cat {destination_in_container}") - - # Then - assert result.exit_code == 0 - assert result.output == b"hello world" +@pytest.fixture(name="copy_source", params=(bytes, Path)) +def copy_source_fixture(request, tmp_path: Path): + """ + Provide source argument for tests of copy_into_container + """ + raw_data = b"hello world" + if request.param is bytes: + return raw_data + elif request.param is Path: + my_file = tmp_path / "my_file" + my_file.write_bytes(raw_data) + return my_file + pytest.fail("Invalid type") -def test_copy_file_into_container_via_initializer(tmp_path: Path): +def test_copy_into_container_at_runtime(copy_source: Union[bytes, Path]): # Given - my_file = tmp_path / "my_file" - my_file.write_text("hello world") - destination_in_container = "/tmp/my_file" - transferables = [Transferable(my_file, destination_in_container)] - - with DockerContainer("bash", command="sleep infinity", transferables=transferables) as container: - # When - result = container.exec(f"cat {destination_in_container}") - - # Then - assert result.exit_code == 0 - assert result.output == b"hello world" - - -def test_copy_bytes_to_container_at_runtime(): - # Given - file_content = b"hello world" destination_in_container = "/tmp/my_file" with DockerContainer("bash", command="sleep infinity") as container: # When - container.copy_into_container(file_content, destination_in_container) - - # Then + container.copy_into_container(copy_source, destination_in_container) result = container.exec(f"cat {destination_in_container}") + # Then assert result.exit_code == 0 assert result.output == b"hello world" -def test_copy_bytes_to_container_at_startup(): +def test_copy_into_container_at_startup(copy_source: Union[bytes, Path]): # Given - file_content = b"hello world" destination_in_container = "/tmp/my_file" container = DockerContainer("bash", command="sleep infinity") - container.with_copy_into_container(file_content, destination_in_container) + container.with_copy_into_container(copy_source, destination_in_container) with container: # When @@ -132,11 +96,10 @@ def test_copy_bytes_to_container_at_startup(): assert result.output == b"hello world" -def test_copy_bytes_to_container_via_initializer(): +def test_copy_into_container_via_initializer(copy_source: Union[bytes, Path]): # Given - file_content = b"hello world" destination_in_container = "/tmp/my_file" - transferables = [Transferable(file_content, destination_in_container)] + transferables = [Transferable(copy_source, destination_in_container)] with DockerContainer("bash", command="sleep infinity", transferables=transferables) as container: # When From 3d5e0784a551f97c2a7ed1838a561e0cdf1fd5ff Mon Sep 17 00:00:00 2001 From: Ryan Hoban Date: Thu, 11 Sep 2025 03:45:33 +0000 Subject: [PATCH 5/6] Make Transferable simply a Union --- core/testcontainers/core/container.py | 38 ++++++++++++------------ core/testcontainers/core/transferable.py | 12 ++------ core/tests/test_core.py | 19 ++++++------ 3 files changed, 30 insertions(+), 39 deletions(-) diff --git a/core/testcontainers/core/container.py b/core/testcontainers/core/container.py index ea91e23fe..12cff3d76 100644 --- a/core/testcontainers/core/container.py +++ b/core/testcontainers/core/container.py @@ -20,7 +20,7 @@ from testcontainers.core.exceptions import ContainerConnectException, ContainerStartException from testcontainers.core.labels import LABEL_SESSION_ID, SESSION_ID from testcontainers.core.network import Network -from testcontainers.core.transferable import Transferable +from testcontainers.core.transferable import Transferable, TransferSpec from testcontainers.core.utils import is_arm, setup_logger from testcontainers.core.wait_strategies import LogMessageWaitStrategy from testcontainers.core.waiting_utils import WaitStrategy, wait_container_is_ready @@ -75,7 +75,7 @@ def __init__( network: Optional[Network] = None, network_aliases: Optional[list[str]] = None, _wait_strategy: Optional[WaitStrategy] = None, - transferables: Optional[list[Transferable]] = None, + transferables: Optional[list[TransferSpec]] = None, **kwargs: Any, ) -> None: self.env = env or {} @@ -104,7 +104,11 @@ def __init__( self._kwargs = kwargs self._wait_strategy: Optional[WaitStrategy] = _wait_strategy - self._transferables: list[Transferable] = transferables or [] + + self._transferable_specs: list[TransferSpec] = [] + if transferables: + for t in transferables: + self.with_copy_into_container(*t) def with_env(self, key: str, value: str) -> Self: self.env[key] = value @@ -214,8 +218,8 @@ def start(self) -> Self: logger.info("Container started: %s", self._container.short_id) - for t in self._transferables: - self._transfer_into_container(t.source, t.destination_in_container, t.mode) + for t in self._transferable_specs: + self._transfer_into_container(*t) return self @@ -309,24 +313,20 @@ def _configure(self) -> None: pass def with_copy_into_container( - self, file_content: Union[bytes, pathlib.Path], destination_in_container: str, mode: int = 0o644 + self, transferable: Transferable, destination_in_container: str, mode: int = 0o644 ) -> Self: - self._transferables.append(Transferable(file_content, destination_in_container, mode)) + self._transferable_specs.append((transferable, destination_in_container, mode)) return self - def copy_into_container( - self, file_content: Union[bytes, pathlib.Path], destination_in_container: str, mode: int = 0o644 - ) -> None: - return self._transfer_into_container(file_content, destination_in_container, mode) + def copy_into_container(self, transferable: Transferable, destination_in_container: str, mode: int = 0o644) -> None: + return self._transfer_into_container(transferable, destination_in_container, mode) - def _transfer_into_container( - self, source: Union[bytes, pathlib.Path], destination_in_container: str, mode: int - ) -> None: - if isinstance(source, bytes): - file_content = source - elif isinstance(source, pathlib.Path): - assert source.is_file() # Temporary, only copying file supported - file_content = source.read_bytes() + def _transfer_into_container(self, transferable: Transferable, destination_in_container: str, mode: int) -> None: + if isinstance(transferable, bytes): + file_content = transferable + elif isinstance(transferable, pathlib.Path): + assert transferable.is_file() # Temporary, only copying file supported + file_content = transferable.read_bytes() else: raise TypeError("source must be bytes or PathLike") diff --git a/core/testcontainers/core/transferable.py b/core/testcontainers/core/transferable.py index 5d3f6a805..4d3439ca3 100644 --- a/core/testcontainers/core/transferable.py +++ b/core/testcontainers/core/transferable.py @@ -1,14 +1,6 @@ -import dataclasses import pathlib from typing import Union +Transferable = Union[bytes, pathlib.Path] -@dataclasses.dataclass -class Transferable: - """ - Wrapper class enabling copying files into a container - """ - - source: Union[bytes, pathlib.Path] - destination_in_container: str - mode: int = 0o644 +TransferSpec = Union[tuple[Transferable, str], tuple[Transferable, str, int]] diff --git a/core/tests/test_core.py b/core/tests/test_core.py index 0b92b98c1..958440a8b 100644 --- a/core/tests/test_core.py +++ b/core/tests/test_core.py @@ -1,10 +1,9 @@ import tempfile from pathlib import Path -from typing import Union import pytest from testcontainers.core.container import DockerContainer -from testcontainers.core.transferable import Transferable +from testcontainers.core.transferable import Transferable, TransferSpec def test_garbage_collection_is_defensive(): @@ -51,8 +50,8 @@ def test_docker_container_with_env_file(): print(output) -@pytest.fixture(name="copy_source", params=(bytes, Path)) -def copy_source_fixture(request, tmp_path: Path): +@pytest.fixture(name="transferable", params=(bytes, Path)) +def copy_sources_fixture(request, tmp_path: Path): """ Provide source argument for tests of copy_into_container """ @@ -66,13 +65,13 @@ def copy_source_fixture(request, tmp_path: Path): pytest.fail("Invalid type") -def test_copy_into_container_at_runtime(copy_source: Union[bytes, Path]): +def test_copy_into_container_at_runtime(transferable: Transferable): # Given destination_in_container = "/tmp/my_file" with DockerContainer("bash", command="sleep infinity") as container: # When - container.copy_into_container(copy_source, destination_in_container) + container.copy_into_container(transferable, destination_in_container) result = container.exec(f"cat {destination_in_container}") # Then @@ -80,12 +79,12 @@ def test_copy_into_container_at_runtime(copy_source: Union[bytes, Path]): assert result.output == b"hello world" -def test_copy_into_container_at_startup(copy_source: Union[bytes, Path]): +def test_copy_into_container_at_startup(transferable: Transferable): # Given destination_in_container = "/tmp/my_file" container = DockerContainer("bash", command="sleep infinity") - container.with_copy_into_container(copy_source, destination_in_container) + container.with_copy_into_container(transferable, destination_in_container) with container: # When @@ -96,10 +95,10 @@ def test_copy_into_container_at_startup(copy_source: Union[bytes, Path]): assert result.output == b"hello world" -def test_copy_into_container_via_initializer(copy_source: Union[bytes, Path]): +def test_copy_into_container_via_initializer(transferable: Transferable): # Given destination_in_container = "/tmp/my_file" - transferables = [Transferable(copy_source, destination_in_container)] + transferables: list[TransferSpec] = [(transferable, destination_in_container, 0o644)] with DockerContainer("bash", command="sleep infinity", transferables=transferables) as container: # When From 1fd9831a57cf5e8b00f732cf224fd985d42649f6 Mon Sep 17 00:00:00 2001 From: Ryan Hoban Date: Fri, 12 Sep 2025 01:52:36 +0000 Subject: [PATCH 6/6] Implement & test copying a directory --- core/testcontainers/core/container.py | 27 ++++++++++++++++++++++--- core/tests/test_core.py | 29 +++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/core/testcontainers/core/container.py b/core/testcontainers/core/container.py index 12cff3d76..0cd18e8fd 100644 --- a/core/testcontainers/core/container.py +++ b/core/testcontainers/core/container.py @@ -323,13 +323,20 @@ def copy_into_container(self, transferable: Transferable, destination_in_contain def _transfer_into_container(self, transferable: Transferable, destination_in_container: str, mode: int) -> None: if isinstance(transferable, bytes): - file_content = transferable + self._transfer_file_content_into_container(transferable, destination_in_container, mode) elif isinstance(transferable, pathlib.Path): - assert transferable.is_file() # Temporary, only copying file supported - file_content = transferable.read_bytes() + if transferable.is_file(): + self._transfer_file_content_into_container(transferable.read_bytes(), destination_in_container, mode) + elif transferable.is_dir(): + self._transfer_directory_into_container(transferable, destination_in_container, mode) + else: + raise TypeError(f"Path {transferable} is neither a file nor directory") else: raise TypeError("source must be bytes or PathLike") + def _transfer_file_content_into_container( + self, file_content: bytes, destination_in_container: str, mode: int + ) -> None: fileobj = io.BytesIO() with tarfile.open(fileobj=fileobj, mode="w") as tar: tarinfo = tarfile.TarInfo(name=destination_in_container) @@ -341,6 +348,20 @@ def _transfer_into_container(self, transferable: Transferable, destination_in_co rv = self._container.put_archive(path="/", data=fileobj.getvalue()) assert rv is True + def _transfer_directory_into_container( + self, source_directory: pathlib.Path, destination_in_container: str, mode: int + ) -> None: + assert self._container is not None + result = self._container.exec_run(["mkdir", "-p", destination_in_container]) + assert result.exit_code == 0 + + fileobj = io.BytesIO() + with tarfile.open(fileobj=fileobj, mode="w") as tar: + tar.add(source_directory, arcname=source_directory.name) + fileobj.seek(0) + rv = self._container.put_archive(path=destination_in_container, data=fileobj.getvalue()) + assert rv is True + def copy_from_container(self, source_in_container: str, destination_on_host: pathlib.Path) -> None: assert self._container is not None tar_stream, _ = self._container.get_archive(source_in_container) diff --git a/core/tests/test_core.py b/core/tests/test_core.py index 958440a8b..fd2323e65 100644 --- a/core/tests/test_core.py +++ b/core/tests/test_core.py @@ -125,3 +125,32 @@ def test_copy_file_from_container(tmp_path: Path): # Then assert destination_on_host.is_file() assert destination_on_host.read_text() == "hello world" + + +def test_copy_directory_into_container(tmp_path: Path): + # Given + source_dir = tmp_path / "my_directory" + source_dir.mkdir() + my_file = source_dir / "my_file" + my_file.write_bytes(b"hello world") + + destination_in_container = "/tmp/my_destination_directory" + + with DockerContainer("bash", command="sleep infinity") as container: + # When + container.copy_into_container(source_dir, destination_in_container) + result = container.exec(f"ls {destination_in_container}") + + # Then - my_directory exists + assert result.exit_code == 0 + assert result.output == b"my_directory\n" + + # Then - my_file is in directory + result = container.exec(f"ls {destination_in_container}/my_directory") + assert result.exit_code == 0 + assert result.output == b"my_file\n" + + # Then - my_file contents are correct + result = container.exec(f"cat {destination_in_container}/my_directory/my_file") + assert result.exit_code == 0 + assert result.output == b"hello world"