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"