Skip to content

Commit 0c87901

Browse files
committed
feat: support with_copy_to
1 parent 44dd40b commit 0c87901

File tree

6 files changed

+179
-5
lines changed

6 files changed

+179
-5
lines changed

core/testcontainers/core/container.py

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import contextlib
22
import sys
33
from os import PathLike
4+
from pathlib import Path
45
from socket import socket
56
from types import TracebackType
67
from typing import TYPE_CHECKING, Any, Optional, TypedDict, Union, cast
@@ -18,7 +19,7 @@
1819
from testcontainers.core.exceptions import ContainerConnectException, ContainerStartException
1920
from testcontainers.core.labels import LABEL_SESSION_ID, SESSION_ID
2021
from testcontainers.core.network import Network
21-
from testcontainers.core.utils import is_arm, setup_logger
22+
from testcontainers.core.utils import build_tar_file, is_arm, setup_logger
2223
from testcontainers.core.wait_strategies import LogMessageWaitStrategy
2324
from testcontainers.core.waiting_utils import WaitStrategy
2425

@@ -97,6 +98,7 @@ def __init__(
9798

9899
self._kwargs = kwargs
99100
self._wait_strategy: Optional[WaitStrategy] = _wait_strategy
101+
self._copy_to_container: list[tuple[str, Union[bytes, Path]]] = []
100102

101103
def with_env(self, key: str, value: str) -> Self:
102104
self.env[key] = value
@@ -190,17 +192,21 @@ def start(self) -> Self:
190192
else {}
191193
)
192194

193-
self._container = docker_client.run(
195+
self._container = docker_client.create(
194196
self.image,
195197
command=self._command,
196-
detach=True,
197198
environment=self.env,
198199
ports=cast("dict[int, Optional[int]]", self.ports),
199200
name=self._name,
200201
volumes=self.volumes,
201202
**{**network_kwargs, **self._kwargs},
202203
)
203204

205+
for target, source in self._copy_to_container:
206+
self._container.put_archive("/", build_tar_file(target, source))
207+
208+
docker_client.start(self._container)
209+
204210
if self._wait_strategy is not None:
205211
self._wait_strategy.wait_until_ready(self)
206212

@@ -270,6 +276,27 @@ def with_volume_mapping(self, host: Union[str, PathLike[str]], container: str, m
270276
self.volumes[str(host)] = mapping
271277
return self
272278

279+
def with_copy_to(self, target: str, source: Union[bytes, str, PathLike[str]]) -> Self:
280+
"""
281+
Copy a file, directory, or raw bytes into the container at startup.
282+
283+
:param target: Absolute path inside the container where the data should be placed.
284+
:param source: Either ``bytes``/``bytearray`` (raw file content) or a path
285+
(``str`` / :class:`pathlib.Path`) to a local file or directory.
286+
287+
:doctest:
288+
289+
>>> from testcontainers.core.container import DockerContainer
290+
>>> container = DockerContainer("alpine")
291+
>>> container = container.with_copy_to("/tmp/hello.txt", b"hello world")
292+
293+
"""
294+
if isinstance(source, (bytes, bytearray)):
295+
self._copy_to_container.append((target, bytes(source)))
296+
else:
297+
self._copy_to_container.append((target, Path(source)))
298+
return self
299+
273300
def get_wrapped_container(self) -> "Container":
274301
return self._container
275302

@@ -301,6 +328,13 @@ def exec(self, command: Union[str, list[str]]) -> ExecResult:
301328
raise ContainerStartException("Container should be started before executing a command")
302329
return self._container.exec_run(command)
303330

331+
def wait(self) -> int:
332+
"""Wait for the container to stop and return its exit code."""
333+
if not self._container:
334+
raise ContainerStartException("Container should be started before waiting")
335+
result = self._container.wait()
336+
return int(result["StatusCode"])
337+
304338
def _configure(self) -> None:
305339
# placeholder if subclasses want to define this and use the default start method
306340
pass

core/testcontainers/core/docker_client.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,14 @@ def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T:
4949
return wrapper
5050

5151

52+
def _wrapped_container_collection_create(function: Callable[_P, _T]) -> Callable[_P, _T]:
53+
@ft.wraps(ContainerCollection.create)
54+
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T:
55+
return function(*args, **kwargs)
56+
57+
return wrapper
58+
59+
5260
def _wrapped_image_collection(function: Callable[_P, _T]) -> Callable[_P, _T]:
5361
@ft.wraps(ImageCollection.build)
5462
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T:
@@ -114,6 +122,42 @@ def run(
114122
)
115123
return container
116124

125+
@_wrapped_container_collection_create
126+
def create(
127+
self,
128+
image: str,
129+
command: Optional[Union[str, list[str]]] = None,
130+
environment: Optional[dict[str, str]] = None,
131+
ports: Optional[dict[int, Optional[int]]] = None,
132+
labels: Optional[dict[str, str]] = None,
133+
**kwargs: Any,
134+
) -> Container:
135+
"""Create a container without starting it, pulling the image first if not present locally."""
136+
if "network" not in kwargs and not get_docker_host():
137+
host_network = self.find_host_network()
138+
if host_network:
139+
kwargs["network"] = host_network
140+
141+
try:
142+
# This is more or less a replication of what the self.client.containers.start does internally
143+
self.client.images.get(image)
144+
except docker.errors.ImageNotFound:
145+
self.client.images.pull(image)
146+
147+
container = self.client.containers.create(
148+
image,
149+
command=command,
150+
environment=environment,
151+
ports=ports,
152+
labels=create_labels(image, labels),
153+
**kwargs,
154+
)
155+
return container
156+
157+
def start(self, container: Container) -> None:
158+
"""Start a previously created container."""
159+
container.start()
160+
117161
@_wrapped_image_collection
118162
def build(
119163
self, path: str, tag: Optional[str], rm: bool = True, **kwargs: Any

core/testcontainers/core/utils.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import io
12
import logging
23
import os
34
import platform
45
import subprocess
56
import sys
7+
import tarfile
68
from pathlib import Path
7-
from typing import Any, Final, Optional
9+
from typing import Any, Final, Optional, Union
810

911
LINUX = "linux"
1012
MAC = "mac"
@@ -98,3 +100,20 @@ def get_running_in_container_id() -> Optional[str]:
98100
if path.startswith("/docker"):
99101
return path.removeprefix("/docker/")
100102
return None
103+
104+
105+
def build_tar_file(target: str, source: Union[bytes, Path]) -> bytes:
106+
"""Pack *source* into an in-memory tar archive whose member path equals *target* (relative to /)."""
107+
buf = io.BytesIO()
108+
with tarfile.open(fileobj=buf, mode="w") as tar:
109+
# Docker's put_archive extracts relative to the given path; we upload to "/"
110+
# so the member name must be the target path stripped of its leading slash.
111+
arcname = target.lstrip("/")
112+
if isinstance(source, bytes):
113+
info = tarfile.TarInfo(name=arcname)
114+
info.size = len(source)
115+
info.mode = 0o644
116+
tar.addfile(info, io.BytesIO(source))
117+
else:
118+
tar.add(str(source), arcname=arcname)
119+
return buf.getvalue()

core/tests/test_core.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,50 @@ def test_docker_container_with_env_file():
4646
assert "ADMIN_EMAIL=admin@example.org" in output
4747
assert "ROOT_URL=example.org/app" in output
4848
print(output)
49+
50+
51+
# ---------------------------------------------------------------------------
52+
# with_copy_to
53+
# ---------------------------------------------------------------------------
54+
55+
56+
def test_with_copy_to_bytes():
57+
"""Bytes passed to with_copy_to should be readable inside the running container."""
58+
with (
59+
DockerContainer("alpine")
60+
.with_command(["cat", "/tmp/hello.txt"])
61+
.with_copy_to("/tmp/hello.txt", b"hello from bytes") as c
62+
):
63+
c.wait()
64+
stdout, _ = c.get_logs()
65+
assert stdout.decode() == "hello from bytes"
66+
67+
68+
def test_with_copy_to_file(tmp_path: Path):
69+
"""A local file passed to with_copy_to should be readable inside the running container."""
70+
src = tmp_path / "copied.txt"
71+
src.write_bytes(b"hello from file")
72+
73+
with DockerContainer("alpine").with_command(["cat", "/tmp/copied.txt"]).with_copy_to("/tmp/copied.txt", src) as c:
74+
c.wait()
75+
stdout, _ = c.get_logs()
76+
assert stdout.decode() == "hello from file"
77+
78+
79+
def test_with_copy_to_directory(tmp_path: Path):
80+
"""A local directory passed to with_copy_to should be readable inside the running container."""
81+
(tmp_path / "a.txt").write_text("aaa")
82+
sub = tmp_path / "sub"
83+
sub.mkdir()
84+
(sub / "b.txt").write_text("bbb")
85+
86+
with (
87+
DockerContainer("alpine")
88+
.with_command(["sh", "-c", "cat /mydata/a.txt && cat /mydata/sub/b.txt"])
89+
.with_copy_to("/mydata", tmp_path)
90+
) as c:
91+
c.wait()
92+
stdout, _ = c.get_logs()
93+
output = stdout.decode()
94+
assert "aaa" in output
95+
assert "bbb" in output

core/tests/test_utils.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import io
2+
import tarfile
13
from pathlib import Path
24

35
import pytest
@@ -76,3 +78,32 @@ def test_get_running_container_id(fake_cgroup: Path) -> None:
7678
container_id = "b78eebb08f89158ed6e2ed2fe"
7779
fake_cgroup.write_text(f"13:cpuset:/docker/{container_id}")
7880
assert utils.get_running_in_container_id() == container_id
81+
82+
83+
# ---------------------------------------------------------------------------
84+
# build_copy_to_tar
85+
# ---------------------------------------------------------------------------
86+
87+
88+
def test_build_copy_to_tar_bytes() -> None:
89+
data = b"hello world"
90+
tar_bytes = utils.build_tar_file("/tmp/hello.txt", data)
91+
92+
with tarfile.open(fileobj=io.BytesIO(tar_bytes)) as tar:
93+
members = tar.getmembers()
94+
assert len(members) == 1
95+
assert members[0].name == "tmp/hello.txt"
96+
assert tar.extractfile(members[0]).read() == data
97+
98+
99+
def test_build_copy_to_tar_file(tmp_path: Path) -> None:
100+
src = tmp_path / "myfile.txt"
101+
src.write_bytes(b"file content")
102+
103+
tar_bytes = utils.build_tar_file("/tmp/myfile.txt", src)
104+
105+
with tarfile.open(fileobj=io.BytesIO(tar_bytes)) as tar:
106+
members = tar.getmembers()
107+
assert len(members) == 1
108+
assert members[0].name == "tmp/myfile.txt"
109+
assert tar.extractfile(members[0]).read() == b"file content"

modules/oracle-free/example_basic.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import oracledb
2-
32
from testcontainers.oracle_free import OracleFreeContainer
43

54

0 commit comments

Comments
 (0)