diff --git a/core/README.rst b/core/README.rst index 7403d266..2dd4f33e 100644 --- a/core/README.rst +++ b/core/README.rst @@ -16,6 +16,8 @@ Testcontainers Core .. autoclass:: testcontainers.core.wait_strategies.WaitStrategy +.. autoclass:: testcontainers.core.transferable.Transferable + .. raw:: html
diff --git a/core/testcontainers/core/container.py b/core/testcontainers/core/container.py index dc5470c3..0cd18e8f 100644 --- a/core/testcontainers/core/container.py +++ b/core/testcontainers/core/container.py @@ -1,4 +1,7 @@ import contextlib +import io +import pathlib +import tarfile from os import PathLike from socket import socket from types import TracebackType @@ -17,6 +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, 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 @@ -71,6 +75,7 @@ def __init__( network: Optional[Network] = None, network_aliases: Optional[list[str]] = None, _wait_strategy: Optional[WaitStrategy] = None, + transferables: Optional[list[TransferSpec]] = None, **kwargs: Any, ) -> None: self.env = env or {} @@ -100,6 +105,11 @@ def __init__( self._kwargs = kwargs self._wait_strategy: Optional[WaitStrategy] = _wait_strategy + 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 return self @@ -207,6 +217,10 @@ def start(self) -> Self: self._wait_strategy.wait_until_ready(self) logger.info("Container started: %s", self._container.short_id) + + for t in self._transferable_specs: + self._transfer_into_container(*t) + return self def stop(self, force: bool = True, delete_volume: bool = True) -> None: @@ -298,6 +312,68 @@ def _configure(self) -> None: # placeholder if subclasses want to define this and use the default start method pass + def with_copy_into_container( + self, transferable: Transferable, destination_in_container: str, mode: int = 0o644 + ) -> Self: + self._transferable_specs.append((transferable, destination_in_container, mode)) + return self + + 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, transferable: Transferable, destination_in_container: str, mode: int) -> None: + if isinstance(transferable, bytes): + self._transfer_file_content_into_container(transferable, destination_in_container, mode) + elif isinstance(transferable, pathlib.Path): + 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) + tarinfo.size = len(file_content) + 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 _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) + + 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: + 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 new file mode 100644 index 00000000..4d3439ca --- /dev/null +++ b/core/testcontainers/core/transferable.py @@ -0,0 +1,6 @@ +import pathlib +from typing import Union + +Transferable = Union[bytes, pathlib.Path] + +TransferSpec = Union[tuple[Transferable, str], tuple[Transferable, str, int]] diff --git a/core/tests/test_core.py b/core/tests/test_core.py index 9312b0bc..fd2323e6 100644 --- a/core/tests/test_core.py +++ b/core/tests/test_core.py @@ -1,7 +1,9 @@ import tempfile from pathlib import Path +import pytest from testcontainers.core.container import DockerContainer +from testcontainers.core.transferable import Transferable, TransferSpec def test_garbage_collection_is_defensive(): @@ -46,3 +48,109 @@ 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) + + +@pytest.fixture(name="transferable", params=(bytes, Path)) +def copy_sources_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_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(transferable, 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_into_container_at_startup(transferable: Transferable): + # Given + destination_in_container = "/tmp/my_file" + + container = DockerContainer("bash", command="sleep infinity") + container.with_copy_into_container(transferable, 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_into_container_via_initializer(transferable: Transferable): + # Given + destination_in_container = "/tmp/my_file" + transferables: list[TransferSpec] = [(transferable, destination_in_container, 0o644)] + + 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_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" + + +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"