Skip to content

Commit 4c8007b

Browse files
authored
Merge branch 'main' into valkey_test_container
2 parents 2fd47f8 + baa5668 commit 4c8007b

File tree

33 files changed

+844
-61
lines changed

33 files changed

+844
-61
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
@@ -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,35 @@ 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 not self._container:
346+
raise ContainerStartException("Container must be started before transferring files")
347+
348+
data = build_transfer_tar(transferable, destination_in_container, mode)
349+
if not self._container.put_archive(path="/", data=data):
350+
raise OSError(f"Failed to put archive into container at {destination_in_container}")
351+
352+
def copy_from_container(self, source_in_container: str, destination_on_host: pathlib.Path) -> None:
353+
if not self._container:
354+
raise ContainerStartException("Container must be started before copying files")
355+
356+
tar_stream, _ = self._container.get_archive(source_in_container)
357+
358+
with tarfile.open(fileobj=io.BytesIO(b"".join(tar_stream))) as tar:
359+
for member in tar.getmembers():
360+
extracted = tar.extractfile(member)
361+
if extracted is not None:
362+
destination_on_host.write_bytes(extracted.read())
363+
308364

309365
class Reaper:
310366
"""

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)