11import contextlib
2+ import io
3+ import pathlib
24import sys
5+ import tarfile
36from os import PathLike
47from socket import socket
58from types import TracebackType
1821from testcontainers .core .exceptions import ContainerConnectException , ContainerStartException
1922from testcontainers .core .labels import LABEL_SESSION_ID , SESSION_ID
2023from testcontainers .core .network import Network
24+ from testcontainers .core .transferable import Transferable , TransferSpec
2125from testcontainers .core .utils import is_arm , setup_logger
2226from testcontainers .core .wait_strategies import LogMessageWaitStrategy
2327from 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
309398class Reaper :
310399 """
0 commit comments