Skip to content

Commit 8563d69

Browse files
Merge branch 'main' into add-management-relative-path
2 parents bcfd376 + 83157eb commit 8563d69

File tree

18 files changed

+681
-10
lines changed

18 files changed

+681
-10
lines changed

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/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_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"

modules/generic/README.rst

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ FastAPI container that is using :code:`ServerContainer`
99

1010
>>> from testcontainers.generic import ServerContainer
1111
>>> from testcontainers.core.waiting_utils import wait_for_logs
12+
>>> from testcontainers.core.image import DockerImage
1213

1314
>>> with DockerImage(path="./modules/generic/tests/samples/fastapi", tag="fastapi-test:latest") as image:
1415
... with ServerContainer(port=80, image=image) as fastapi_server:
@@ -50,3 +51,39 @@ A more advance use-case, where we are using a FastAPI container that is using Re
5051
... response = client.get(f"/get/{test_data['key']}")
5152
... assert response.status_code == 200, "Failed to get data"
5253
... assert response.json() == {"key": test_data["key"], "value": test_data["value"]}
54+
55+
.. autoclass:: testcontainers.generic.SqlContainer
56+
.. title:: testcontainers.generic.SqlContainer
57+
58+
Postgres container that is using :code:`SqlContainer`
59+
60+
.. doctest::
61+
62+
>>> from testcontainers.generic import SqlContainer
63+
>>> from testcontainers.generic.providers.sql_connection_wait_strategy import SqlAlchemyConnectWaitStrategy
64+
>>> from sqlalchemy import text
65+
>>> import sqlalchemy
66+
67+
>>> class CustomPostgresContainer(SqlContainer):
68+
... def __init__(self, image="postgres:15-alpine",
69+
... port=5432, username="test", password="test", dbname="test"):
70+
... super().__init__(image=image, wait_strategy=SqlAlchemyConnectWaitStrategy())
71+
... self.port_to_expose = port
72+
... self.username = username
73+
... self.password = password
74+
... self.dbname = dbname
75+
... def get_connection_url(self) -> str:
76+
... host = self.get_container_host_ip()
77+
... port = self.get_exposed_port(self.port_to_expose)
78+
... return f"postgresql://{self.username}:{self.password}@{host}:{port}/{self.dbname}"
79+
... def _configure(self) -> None:
80+
... self.with_exposed_ports(self.port_to_expose)
81+
... self.with_env("POSTGRES_USER", self.username)
82+
... self.with_env("POSTGRES_PASSWORD", self.password)
83+
... self.with_env("POSTGRES_DB", self.dbname)
84+
85+
>>> with CustomPostgresContainer() as postgres:
86+
... engine = sqlalchemy.create_engine(postgres.get_connection_url())
87+
... with engine.connect() as conn:
88+
... result = conn.execute(text("SELECT 1"))
89+
... assert result.scalar() == 1
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
from .server import ServerContainer # noqa: F401
2+
from .sql import SqlContainer # noqa: F401
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .sql_connection_wait_strategy import SqlAlchemyConnectWaitStrategy # noqa: F401
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# This module provides a wait strategy for SQL database connectivity testing using SQLAlchemy.
2+
# It includes handling for transient exceptions and connection retries.
3+
4+
import logging
5+
6+
from testcontainers.core.waiting_utils import WaitStrategy, WaitStrategyTarget
7+
8+
logger = logging.getLogger(__name__)
9+
10+
ADDITIONAL_TRANSIENT_ERRORS = []
11+
try:
12+
from sqlalchemy.exc import DBAPIError
13+
14+
ADDITIONAL_TRANSIENT_ERRORS.append(DBAPIError)
15+
except ImportError:
16+
logger.debug("SQLAlchemy not available, skipping DBAPIError handling")
17+
18+
19+
class SqlAlchemyConnectWaitStrategy(WaitStrategy):
20+
"""Wait strategy for database connectivity testing using SQLAlchemy."""
21+
22+
def __init__(self):
23+
super().__init__()
24+
self.with_transient_exceptions(TimeoutError, ConnectionError, *ADDITIONAL_TRANSIENT_ERRORS)
25+
26+
def wait_until_ready(self, container: WaitStrategyTarget) -> None:
27+
"""Test database connectivity with retry logic until success or timeout."""
28+
if not hasattr(container, "get_connection_url"):
29+
raise AttributeError(f"Container {container} must have a get_connection_url method")
30+
31+
try:
32+
import sqlalchemy
33+
except ImportError as e:
34+
raise ImportError("SQLAlchemy is required for database containers") from e
35+
36+
def _test_connection() -> bool:
37+
"""Test database connection, returning True if successful."""
38+
engine = sqlalchemy.create_engine(container.get_connection_url())
39+
try:
40+
with engine.connect():
41+
logger.info("Database connection successful")
42+
return True
43+
finally:
44+
engine.dispose()
45+
46+
result = self._poll(_test_connection)
47+
if not result:
48+
raise TimeoutError(f"Database connection failed after {self._startup_timeout}s timeout")

0 commit comments

Comments
 (0)