Skip to content

Commit d47b8d8

Browse files
authored
Merge branch 'main' into feature/857
2 parents a66bee2 + 0e0bb24 commit d47b8d8

29 files changed

+829
-37
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: 19 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>
@@ -49,3 +51,20 @@ Using `DockerContainer` and `DockerImage` to create a container:
4951

5052
The `DockerImage` class is used to build the image from the specified path and tag.
5153
The `DockerContainer` class is then used to create a container from the image.
54+
55+
Copying a file from disk into a container:
56+
57+
.. doctest::
58+
59+
>>> import tempfile
60+
>>> from pathlib import Path
61+
>>> from testcontainers.core.container import DockerContainer
62+
63+
>>> with tempfile.TemporaryDirectory() as tmp:
64+
... my_file = Path(tmp) / "my_file.txt"
65+
... _ = my_file.write_text("file content")
66+
... with DockerContainer("bash", command="sleep infinity") as container:
67+
... container.copy_into_container(my_file, "/tmp/my_file.txt")
68+
... result = container.exec("cat /tmp/my_file.txt")
69+
... result.output
70+
b'file content'

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: 56 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, build_transfer_tar
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
@@ -99,6 +106,11 @@ def __init__(
99106
self._wait_strategy: Optional[WaitStrategy] = _wait_strategy
100107
self._cached_container_info: Optional[ContainerInspectInfo] = None
101108

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

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

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

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

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

@@ -327,6 +354,35 @@ def _configure(self) -> None:
327354
# placeholder if subclasses want to define this and use the default start method
328355
pass
329356

357+
def with_copy_into_container(
358+
self, transferable: Transferable, destination_in_container: str, mode: int = 0o644
359+
) -> Self:
360+
self._transferable_specs.append((transferable, destination_in_container, mode))
361+
return self
362+
363+
def copy_into_container(self, transferable: Transferable, destination_in_container: str, mode: int = 0o644) -> None:
364+
return self._transfer_into_container(transferable, destination_in_container, mode)
365+
366+
def _transfer_into_container(self, transferable: Transferable, destination_in_container: str, mode: int) -> None:
367+
if not self._container:
368+
raise ContainerStartException("Container must be started before transferring files")
369+
370+
data = build_transfer_tar(transferable, destination_in_container, mode)
371+
if not self._container.put_archive(path="/", data=data):
372+
raise OSError(f"Failed to put archive into container at {destination_in_container}")
373+
374+
def copy_from_container(self, source_in_container: str, destination_on_host: pathlib.Path) -> None:
375+
if not self._container:
376+
raise ContainerStartException("Container must be started before copying files")
377+
378+
tar_stream, _ = self._container.get_archive(source_in_container)
379+
380+
with tarfile.open(fileobj=io.BytesIO(b"".join(tar_stream))) as tar:
381+
for member in tar.getmembers():
382+
extracted = tar.extractfile(member)
383+
if extracted is not None:
384+
destination_on_host.write_bytes(extracted.read())
385+
330386

331387
class Reaper:
332388
"""

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: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import io
2+
import pathlib
3+
import tarfile
4+
from typing import Union
5+
6+
Transferable = Union[bytes, pathlib.Path]
7+
8+
TransferSpec = Union[tuple[Transferable, str], tuple[Transferable, str, int]]
9+
10+
11+
def build_transfer_tar(transferable: Transferable, destination: str, mode: int = 0o644) -> bytes:
12+
"""Build a tar archive containing the transferable, ready for put_archive(path="/")."""
13+
buf = io.BytesIO()
14+
with tarfile.open(fileobj=buf, mode="w") as tar:
15+
if isinstance(transferable, bytes):
16+
info = tarfile.TarInfo(name=destination)
17+
info.size = len(transferable)
18+
info.mode = mode
19+
tar.addfile(info, io.BytesIO(transferable))
20+
elif isinstance(transferable, pathlib.Path):
21+
if transferable.is_file():
22+
info = tarfile.TarInfo(name=destination)
23+
info.size = transferable.stat().st_size
24+
info.mode = mode
25+
with transferable.open("rb") as f:
26+
tar.addfile(info, f)
27+
elif transferable.is_dir():
28+
tar.add(str(transferable), arcname=f"{destination.rstrip('/')}/{transferable.name}")
29+
else:
30+
raise TypeError(f"Path {transferable} is neither a file nor directory")
31+
else:
32+
raise TypeError("source must be bytes or Path")
33+
return buf.getvalue()

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_docker_in_docker.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ def test_find_host_network_in_dood() -> None:
179179
assert DockerClient().find_host_network() == os.environ[EXPECTED_NETWORK_VAR]
180180

181181

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

221222

223+
@pytest.mark.long_running
222224
@pytest.mark.skipif(
223225
is_mac(),
224226
reason="Docker socket mounting and container networking do not work reliably on Docker Desktop for macOS",

0 commit comments

Comments
 (0)