Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions core/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ Testcontainers Core

.. autoclass:: testcontainers.core.wait_strategies.WaitStrategy

.. autoclass:: testcontainers.core.transferable.Transferable

.. raw:: html

<hr>
Expand Down
76 changes: 76 additions & 0 deletions core/testcontainers/core/container.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import contextlib
import io
import pathlib
import tarfile
from os import PathLike
from socket import socket
from types import TracebackType
Expand All @@ -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
Expand Down Expand Up @@ -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 {}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
"""
Expand Down
6 changes: 6 additions & 0 deletions core/testcontainers/core/transferable.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import pathlib
from typing import Union

Transferable = Union[bytes, pathlib.Path]

TransferSpec = Union[tuple[Transferable, str], tuple[Transferable, str, int]]
108 changes: 108 additions & 0 deletions core/tests/test_core.py
Original file line number Diff line number Diff line change
@@ -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():
Expand Down Expand Up @@ -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"