Skip to content

Commit 6bd25e5

Browse files
authored
Merge branch 'main' into fix/generic-server-wait-strategy
2 parents 129bfc6 + 83157eb commit 6bd25e5

27 files changed

+750
-33
lines changed
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
".": "4.14.1"
2+
".": "4.14.2"
33
}

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## [4.14.2](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.14.1...testcontainers-v4.14.2) (2026-03-18)
4+
5+
6+
### Features
7+
8+
* **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))
9+
310
## [4.14.1](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.14.0...testcontainers-v4.14.1) (2026-01-31)
411

512

Makefile

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ build: ## Build the python package
2020

2121
tests: ${TESTS} ## Run tests for each package
2222
${TESTS}: %/tests:
23-
uv run coverage run --parallel -m pytest -v $*/tests
23+
uv run coverage run --parallel -m pytest -v $*/tests
24+
25+
quick-core-tests: ## Run core tests excluding long_running
26+
uv run coverage run --parallel -m pytest -v -m "not long_running" core/tests
2427

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

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

6669

6770
# Implements this pattern for autodocumenting Makefiles:

core/README.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ Testcontainers Core
1616

1717
.. autoclass:: testcontainers.core.wait_strategies.WaitStrategy
1818

19+
.. autoclass:: testcontainers.core.transferable.Transferable
20+
1921
.. raw:: html
2022

2123
<hr>

core/testcontainers/core/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ def read_tc_properties() -> dict[str, str]:
8888

8989
@dataclass
9090
class TestcontainersConfiguration:
91+
__test__ = False
92+
9193
def _render_bool(self, env_name: str, prop_name: str) -> bool:
9294
env_val = environ.get(env_name, None)
9395
if env_val is not None:

core/testcontainers/core/container.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import contextlib
2+
import io
3+
import pathlib
24
import sys
5+
import tarfile
36
from os import PathLike
47
from socket import socket
58
from types import TracebackType
@@ -18,6 +21,7 @@
1821
from testcontainers.core.exceptions import ContainerConnectException, ContainerStartException
1922
from testcontainers.core.labels import LABEL_SESSION_ID, SESSION_ID
2023
from testcontainers.core.network import Network
24+
from testcontainers.core.transferable import Transferable, TransferSpec
2125
from testcontainers.core.utils import is_arm, setup_logger
2226
from testcontainers.core.wait_strategies import LogMessageWaitStrategy
2327
from testcontainers.core.waiting_utils import WaitStrategy
@@ -69,6 +73,7 @@ def __init__(
6973
network: Optional[Network] = None,
7074
network_aliases: Optional[list[str]] = None,
7175
_wait_strategy: Optional[WaitStrategy] = None,
76+
transferables: Optional[list[TransferSpec]] = None,
7277
**kwargs: Any,
7378
) -> None:
7479
self.env = env or {}
@@ -82,6 +87,8 @@ def __init__(
8287
for vol in volumes:
8388
self.with_volume_mapping(*vol)
8489

90+
self.tmpfs: dict[str, str] = {}
91+
8592
self.image = image
8693
self._docker = DockerClient(**(docker_client_kw or {}))
8794
self._container: Optional[Container] = None
@@ -98,6 +105,11 @@ def __init__(
98105
self._kwargs = kwargs
99106
self._wait_strategy: Optional[WaitStrategy] = _wait_strategy
100107

108+
self._transferable_specs: list[TransferSpec] = []
109+
if transferables:
110+
for t in transferables:
111+
self.with_copy_into_container(*t)
112+
101113
def with_env(self, key: str, value: str) -> Self:
102114
self.env[key] = value
103115
return self
@@ -198,13 +210,18 @@ def start(self) -> Self:
198210
ports=cast("dict[int, Optional[int]]", self.ports),
199211
name=self._name,
200212
volumes=self.volumes,
213+
tmpfs=self.tmpfs,
201214
**{**network_kwargs, **self._kwargs},
202215
)
203216

204217
if self._wait_strategy is not None:
205218
self._wait_strategy.wait_until_ready(self)
206219

207220
logger.info("Container started: %s", self._container.short_id)
221+
222+
for t in self._transferable_specs:
223+
self._transfer_into_container(*t)
224+
208225
return self
209226

210227
def stop(self, force: bool = True, delete_volume: bool = True) -> None:
@@ -270,6 +287,16 @@ def with_volume_mapping(self, host: Union[str, PathLike[str]], container: str, m
270287
self.volumes[str(host)] = mapping
271288
return self
272289

290+
def with_tmpfs_mount(self, container_path: str, size: Optional[str] = None) -> Self:
291+
"""Mount a tmpfs volume on the container.
292+
293+
:param container_path: Container path to mount tmpfs on (e.g., '/data')
294+
:param size: Optional size limit (e.g., '256m', '1g'). If None, unbounded.
295+
:return: Self for chaining
296+
"""
297+
self.tmpfs[container_path] = size or ""
298+
return self
299+
273300
def get_wrapped_container(self) -> "Container":
274301
return self._container
275302

@@ -305,6 +332,68 @@ def _configure(self) -> None:
305332
# placeholder if subclasses want to define this and use the default start method
306333
pass
307334

335+
def with_copy_into_container(
336+
self, transferable: Transferable, destination_in_container: str, mode: int = 0o644
337+
) -> Self:
338+
self._transferable_specs.append((transferable, destination_in_container, mode))
339+
return self
340+
341+
def copy_into_container(self, transferable: Transferable, destination_in_container: str, mode: int = 0o644) -> None:
342+
return self._transfer_into_container(transferable, destination_in_container, mode)
343+
344+
def _transfer_into_container(self, transferable: Transferable, destination_in_container: str, mode: int) -> None:
345+
if isinstance(transferable, bytes):
346+
self._transfer_file_content_into_container(transferable, destination_in_container, mode)
347+
elif isinstance(transferable, pathlib.Path):
348+
if transferable.is_file():
349+
self._transfer_file_content_into_container(transferable.read_bytes(), destination_in_container, mode)
350+
elif transferable.is_dir():
351+
self._transfer_directory_into_container(transferable, destination_in_container, mode)
352+
else:
353+
raise TypeError(f"Path {transferable} is neither a file nor directory")
354+
else:
355+
raise TypeError("source must be bytes or PathLike")
356+
357+
def _transfer_file_content_into_container(
358+
self, file_content: bytes, destination_in_container: str, mode: int
359+
) -> None:
360+
fileobj = io.BytesIO()
361+
with tarfile.open(fileobj=fileobj, mode="w") as tar:
362+
tarinfo = tarfile.TarInfo(name=destination_in_container)
363+
tarinfo.size = len(file_content)
364+
tarinfo.mode = mode
365+
tar.addfile(tarinfo, io.BytesIO(file_content))
366+
fileobj.seek(0)
367+
assert self._container is not None
368+
rv = self._container.put_archive(path="/", data=fileobj.getvalue())
369+
assert rv is True
370+
371+
def _transfer_directory_into_container(
372+
self, source_directory: pathlib.Path, destination_in_container: str, mode: int
373+
) -> None:
374+
assert self._container is not None
375+
result = self._container.exec_run(["mkdir", "-p", destination_in_container])
376+
assert result.exit_code == 0
377+
378+
fileobj = io.BytesIO()
379+
with tarfile.open(fileobj=fileobj, mode="w") as tar:
380+
tar.add(source_directory, arcname=source_directory.name)
381+
fileobj.seek(0)
382+
rv = self._container.put_archive(path=destination_in_container, data=fileobj.getvalue())
383+
assert rv is True
384+
385+
def copy_from_container(self, source_in_container: str, destination_on_host: pathlib.Path) -> None:
386+
assert self._container is not None
387+
tar_stream, _ = self._container.get_archive(source_in_container)
388+
389+
for chunk in tar_stream:
390+
with tarfile.open(fileobj=io.BytesIO(chunk)) as tar:
391+
for member in tar.getmembers():
392+
with open(destination_on_host, "wb") as f:
393+
fileobj = tar.extractfile(member)
394+
assert fileobj is not None
395+
f.write(fileobj.read())
396+
308397

309398
class Reaper:
310399
"""

core/testcontainers/core/generic.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
class DbContainer(DockerContainer):
3030
"""
3131
**DEPRECATED (for removal)**
32+
Please use database-specific container classes or `SqlContainer` instead.
33+
# from testcontainers.generic.sql import SqlContainer
3234
3335
Generic database container.
3436
"""
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import pathlib
2+
from typing import Union
3+
4+
Transferable = Union[bytes, pathlib.Path]
5+
6+
TransferSpec = Union[tuple[Transferable, str], tuple[Transferable, str, int]]

core/tests/test_compose.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import logging
12
import subprocess
23
from pathlib import Path
34
from re import split
@@ -150,7 +151,7 @@ def test_compose_logs():
150151
assert not line or container.Service in next(iter(line.split("|")))
151152

152153

153-
def test_compose_volumes():
154+
def test_compose_volumes(caplog):
154155
_file_in_volume = "/var/lib/example/data/hello"
155156
volumes = DockerCompose(context=FIXTURES / "basic_volume", keep_volumes=True)
156157
with volumes:
@@ -167,8 +168,11 @@ def test_compose_volumes():
167168
assert "hello" in stdout
168169

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

173177

174178
# noinspection HttpUrlsUsage

core/tests/test_core.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import tempfile
22
from pathlib import Path
33

4+
import pytest
45
from testcontainers.core.container import DockerContainer
6+
from testcontainers.core.transferable import Transferable, TransferSpec
57

68

79
def test_garbage_collection_is_defensive():
@@ -46,3 +48,109 @@ def test_docker_container_with_env_file():
4648
assert "ADMIN_EMAIL=admin@example.org" in output
4749
assert "ROOT_URL=example.org/app" in output
4850
print(output)
51+
52+
53+
@pytest.fixture(name="transferable", params=(bytes, Path))
54+
def copy_sources_fixture(request, tmp_path: Path):
55+
"""
56+
Provide source argument for tests of copy_into_container
57+
"""
58+
raw_data = b"hello world"
59+
if request.param is bytes:
60+
return raw_data
61+
elif request.param is Path:
62+
my_file = tmp_path / "my_file"
63+
my_file.write_bytes(raw_data)
64+
return my_file
65+
pytest.fail("Invalid type")
66+
67+
68+
def test_copy_into_container_at_runtime(transferable: Transferable):
69+
# Given
70+
destination_in_container = "/tmp/my_file"
71+
72+
with DockerContainer("bash", command="sleep infinity") as container:
73+
# When
74+
container.copy_into_container(transferable, destination_in_container)
75+
result = container.exec(f"cat {destination_in_container}")
76+
77+
# Then
78+
assert result.exit_code == 0
79+
assert result.output == b"hello world"
80+
81+
82+
def test_copy_into_container_at_startup(transferable: Transferable):
83+
# Given
84+
destination_in_container = "/tmp/my_file"
85+
86+
container = DockerContainer("bash", command="sleep infinity")
87+
container.with_copy_into_container(transferable, destination_in_container)
88+
89+
with container:
90+
# When
91+
result = container.exec(f"cat {destination_in_container}")
92+
93+
# Then
94+
assert result.exit_code == 0
95+
assert result.output == b"hello world"
96+
97+
98+
def test_copy_into_container_via_initializer(transferable: Transferable):
99+
# Given
100+
destination_in_container = "/tmp/my_file"
101+
transferables: list[TransferSpec] = [(transferable, destination_in_container, 0o644)]
102+
103+
with DockerContainer("bash", command="sleep infinity", transferables=transferables) as container:
104+
# When
105+
result = container.exec(f"cat {destination_in_container}")
106+
107+
# Then
108+
assert result.exit_code == 0
109+
assert result.output == b"hello world"
110+
111+
112+
def test_copy_file_from_container(tmp_path: Path):
113+
# Given
114+
file_in_container = "/tmp/foo.txt"
115+
destination_on_host = tmp_path / "foo.txt"
116+
assert not destination_on_host.is_file()
117+
118+
with DockerContainer("bash", command="sleep infinity") as container:
119+
result = container.exec(f'bash -c "echo -n hello world > {file_in_container}"')
120+
assert result.exit_code == 0
121+
122+
# When
123+
container.copy_from_container(file_in_container, destination_on_host)
124+
125+
# Then
126+
assert destination_on_host.is_file()
127+
assert destination_on_host.read_text() == "hello world"
128+
129+
130+
def test_copy_directory_into_container(tmp_path: Path):
131+
# Given
132+
source_dir = tmp_path / "my_directory"
133+
source_dir.mkdir()
134+
my_file = source_dir / "my_file"
135+
my_file.write_bytes(b"hello world")
136+
137+
destination_in_container = "/tmp/my_destination_directory"
138+
139+
with DockerContainer("bash", command="sleep infinity") as container:
140+
# When
141+
container.copy_into_container(source_dir, destination_in_container)
142+
result = container.exec(f"ls {destination_in_container}")
143+
144+
# Then - my_directory exists
145+
assert result.exit_code == 0
146+
assert result.output == b"my_directory\n"
147+
148+
# Then - my_file is in directory
149+
result = container.exec(f"ls {destination_in_container}/my_directory")
150+
assert result.exit_code == 0
151+
assert result.output == b"my_file\n"
152+
153+
# Then - my_file contents are correct
154+
result = container.exec(f"cat {destination_in_container}/my_directory/my_file")
155+
assert result.exit_code == 0
156+
assert result.output == b"hello world"

0 commit comments

Comments
 (0)