From c2cda8023953bfc202b3b84a62f2667c9e63225e Mon Sep 17 00:00:00 2001 From: Ryan Hoban Date: Mon, 28 Jul 2025 19:16:39 +0000 Subject: [PATCH 1/3] 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/3] 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/3] 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}")