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: 1 addition & 1 deletion .github/.release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "4.14.1"
".": "4.14.2"
}
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## [4.14.2](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.14.1...testcontainers-v4.14.2) (2026-03-18)


### Features

* **kafka:** allow configurable listener name and security protocol ([#966](https://github.com/testcontainers/testcontainers-python/issues/966)) ([44dd40b](https://github.com/testcontainers/testcontainers-python/commit/44dd40b48c3a5020b487bae5d460124d9e594ac3))

## [4.14.1](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.14.0...testcontainers-v4.14.1) (2026-01-31)


Expand Down
7 changes: 5 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ build: ## Build the python package

tests: ${TESTS} ## Run tests for each package
${TESTS}: %/tests:
uv run coverage run --parallel -m pytest -v $*/tests
uv run coverage run --parallel -m pytest -v $*/tests

quick-core-tests: ## Run core tests excluding long_running
uv run coverage run --parallel -m pytest -v -m "not long_running" core/tests

coverage: ## Target to combine and report coverage.
uv run coverage combine
Expand Down Expand Up @@ -61,7 +64,7 @@ clean-all: clean ## Remove all generated files and reset the local virtual envir
rm -rf .venv

# Targets that do not generate file-level artifacts.
.PHONY: clean docs doctests image tests ${TESTS}
.PHONY: clean docs doctests image tests quick-core-tests ${TESTS}


# Implements this pattern for autodocumenting Makefiles:
Expand Down
19 changes: 19 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 Expand Up @@ -49,3 +51,20 @@ Using `DockerContainer` and `DockerImage` to create a container:

The `DockerImage` class is used to build the image from the specified path and tag.
The `DockerContainer` class is then used to create a container from the image.

Copying a file from disk into a container:

.. doctest::

>>> import tempfile
>>> from pathlib import Path
>>> from testcontainers.core.container import DockerContainer

>>> with tempfile.TemporaryDirectory() as tmp:
... my_file = Path(tmp) / "my_file.txt"
... _ = my_file.write_text("file content")
... with DockerContainer("bash", command="sleep infinity") as container:
... container.copy_into_container(my_file, "/tmp/my_file.txt")
... result = container.exec("cat /tmp/my_file.txt")
... result.output
b'file content'
2 changes: 2 additions & 0 deletions core/testcontainers/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ def read_tc_properties() -> dict[str, str]:

@dataclass
class TestcontainersConfiguration:
__test__ = False

def _render_bool(self, env_name: str, prop_name: str) -> bool:
env_val = environ.get(env_name, None)
if env_val is not None:
Expand Down
56 changes: 56 additions & 0 deletions core/testcontainers/core/container.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import contextlib
import io
import pathlib
import sys
import tarfile
from os import PathLike
from socket import socket
from types import TracebackType
Expand All @@ -18,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, TransferSpec, build_transfer_tar
from testcontainers.core.utils import is_arm, setup_logger
from testcontainers.core.wait_strategies import LogMessageWaitStrategy
from testcontainers.core.waiting_utils import WaitStrategy
Expand Down Expand Up @@ -69,6 +73,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 All @@ -82,6 +87,8 @@ def __init__(
for vol in volumes:
self.with_volume_mapping(*vol)

self.tmpfs: dict[str, str] = {}

self.image = image
self._docker = DockerClient(**(docker_client_kw or {}))
self._container: Optional[Container] = None
Expand All @@ -98,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 @@ -198,13 +210,18 @@ def start(self) -> Self:
ports=cast("dict[int, Optional[int]]", self.ports),
name=self._name,
volumes=self.volumes,
tmpfs=self.tmpfs,
**{**network_kwargs, **self._kwargs},
)

if self._wait_strategy is not None:
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 @@ -270,6 +287,16 @@ def with_volume_mapping(self, host: Union[str, PathLike[str]], container: str, m
self.volumes[str(host)] = mapping
return self

def with_tmpfs_mount(self, container_path: str, size: Optional[str] = None) -> Self:
"""Mount a tmpfs volume on the container.

:param container_path: Container path to mount tmpfs on (e.g., '/data')
:param size: Optional size limit (e.g., '256m', '1g'). If None, unbounded.
:return: Self for chaining
"""
self.tmpfs[container_path] = size or ""
return self

def get_wrapped_container(self) -> "Container":
return self._container

Expand Down Expand Up @@ -305,6 +332,35 @@ 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 not self._container:
raise ContainerStartException("Container must be started before transferring files")

data = build_transfer_tar(transferable, destination_in_container, mode)
if not self._container.put_archive(path="/", data=data):
raise OSError(f"Failed to put archive into container at {destination_in_container}")

def copy_from_container(self, source_in_container: str, destination_on_host: pathlib.Path) -> None:
if not self._container:
raise ContainerStartException("Container must be started before copying files")

tar_stream, _ = self._container.get_archive(source_in_container)

with tarfile.open(fileobj=io.BytesIO(b"".join(tar_stream))) as tar:
for member in tar.getmembers():
extracted = tar.extractfile(member)
if extracted is not None:
destination_on_host.write_bytes(extracted.read())


class Reaper:
"""
Expand Down
2 changes: 2 additions & 0 deletions core/testcontainers/core/generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
class DbContainer(DockerContainer):
"""
**DEPRECATED (for removal)**
Please use database-specific container classes or `SqlContainer` instead.
# from testcontainers.generic.sql import SqlContainer
Generic database container.
"""
Expand Down
33 changes: 33 additions & 0 deletions core/testcontainers/core/transferable.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import io
import pathlib
import tarfile
from typing import Union

Transferable = Union[bytes, pathlib.Path]

TransferSpec = Union[tuple[Transferable, str], tuple[Transferable, str, int]]


def build_transfer_tar(transferable: Transferable, destination: str, mode: int = 0o644) -> bytes:
"""Build a tar archive containing the transferable, ready for put_archive(path="/")."""
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w") as tar:
if isinstance(transferable, bytes):
info = tarfile.TarInfo(name=destination)
info.size = len(transferable)
info.mode = mode
tar.addfile(info, io.BytesIO(transferable))
elif isinstance(transferable, pathlib.Path):
if transferable.is_file():
info = tarfile.TarInfo(name=destination)
info.size = transferable.stat().st_size
info.mode = mode
with transferable.open("rb") as f:
tar.addfile(info, f)
elif transferable.is_dir():
tar.add(str(transferable), arcname=f"{destination.rstrip('/')}/{transferable.name}")
else:
raise TypeError(f"Path {transferable} is neither a file nor directory")
else:
raise TypeError("source must be bytes or Path")
return buf.getvalue()
10 changes: 7 additions & 3 deletions core/tests/test_compose.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
import subprocess
from pathlib import Path
from re import split
Expand Down Expand Up @@ -150,7 +151,7 @@ def test_compose_logs():
assert not line or container.Service in next(iter(line.split("|")))


def test_compose_volumes():
def test_compose_volumes(caplog):
_file_in_volume = "/var/lib/example/data/hello"
volumes = DockerCompose(context=FIXTURES / "basic_volume", keep_volumes=True)
with volumes:
Expand All @@ -167,8 +168,11 @@ def test_compose_volumes():
assert "hello" in stdout

# third time we expect the file to be missing
with volumes, pytest.raises(subprocess.CalledProcessError):
volumes.exec_in_container(["cat", _file_in_volume], "alpine")
with caplog.at_level(
logging.CRITICAL, logger="testcontainers.compose.compose"
): # suppress expected error logs about missing volume
with volumes, pytest.raises(subprocess.CalledProcessError):
volumes.exec_in_container(["cat", _file_in_volume], "alpine")


# noinspection HttpUrlsUsage
Expand Down
2 changes: 2 additions & 0 deletions core/tests/test_docker_in_docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ def test_find_host_network_in_dood() -> None:
assert DockerClient().find_host_network() == os.environ[EXPECTED_NETWORK_VAR]


@pytest.mark.long_running
@pytest.mark.skipif(
is_mac(),
reason="Docker socket mounting and container networking do not work reliably on Docker Desktop for macOS",
Expand Down Expand Up @@ -219,6 +220,7 @@ def test_dood(python_testcontainer_image: str) -> None:
assert status["StatusCode"] == 0


@pytest.mark.long_running
@pytest.mark.skipif(
is_mac(),
reason="Docker socket mounting and container networking do not work reliably on Docker Desktop for macOS",
Expand Down
28 changes: 26 additions & 2 deletions core/tests/test_ryuk.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from time import sleep
from time import sleep, perf_counter
import pytest
from pytest import MonkeyPatch

Expand All @@ -12,6 +12,27 @@
from testcontainers.core.waiting_utils import wait_for_logs


def _wait_for_container_removed(client: DockerClient, container_id: str, timeout: float = 30) -> None:
"""Poll until a container is fully removed (raises NotFound)."""
start = perf_counter()
while perf_counter() - start < timeout:
try:
client.containers.get(container_id)
except NotFound:
return
sleep(0.5)

try:
c = client.containers.get(container_id)
name = c.name
status = c.status
started_at = c.attrs.get("State", {}).get("StartedAt", "unknown")
detail = f"name={name}, status={status}, started_at={started_at}"
except NotFound:
detail = "container disappeared just after timeout"
raise TimeoutError(f"Container {container_id} was not removed within {timeout}s ({detail})")


@pytest.mark.skipif(
is_mac(),
reason="Ryuk container reaping is unreliable on Docker Desktop for macOS due to VM-based container lifecycle handling",
Expand Down Expand Up @@ -39,8 +60,11 @@ def test_wait_for_reaper(monkeypatch: MonkeyPatch):
assert rs
rs.close()

sleep(0.6) # Sleep until Ryuk reaps all dangling containers. 0.5 extra seconds for good measure.
# Ryuk will reap containers then auto-remove itself.
# Wait for the reaper container to disappear and once it's gone, all labeled containers are guaranteed reaped.
_wait_for_container_removed(docker_client, reaper_id)

# Verify both containers were reaped
with pytest.raises(NotFound):
docker_client.containers.get(container_id)
with pytest.raises(NotFound):
Expand Down
Loading