Skip to content

Commit 911e0b1

Browse files
authored
Merge branch 'main' into influxdb3
2 parents db73679 + c8a5bbd commit 911e0b1

File tree

43 files changed

+2135
-85
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+2135
-85
lines changed

Makefile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ ${TESTS}: %/tests:
2525
quick-core-tests: ## Run core tests excluding long_running
2626
uv run coverage run --parallel -m pytest -v -m "not long_running" core/tests
2727

28+
core-tests: ## Run tests for the core package
29+
uv run coverage run --parallel -m pytest -v core/tests
30+
2831
coverage: ## Target to combine and report coverage.
2932
uv run coverage combine
3033
uv run coverage report

conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,4 +168,5 @@
168168
nitpick_ignore = [
169169
("py:class", "typing_extensions.Self"),
170170
("py:class", "docker.models.containers.ExecResult"),
171+
("py:class", "testcontainers.core.docker_client.ContainerInspectInfo"),
171172
]

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/compose/compose.py

Lines changed: 48 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import sys
2-
from dataclasses import asdict, dataclass, field, fields, is_dataclass
2+
from dataclasses import asdict, dataclass, field
33
from functools import cached_property
44
from json import loads
55
from logging import getLogger, warning
@@ -11,28 +11,16 @@
1111
from types import TracebackType
1212
from typing import Any, Callable, Literal, Optional, TypeVar, Union, cast
1313

14+
from testcontainers.core.docker_client import DockerClient, get_docker_host_hostname
1415
from testcontainers.core.exceptions import ContainerIsNotRunning, NoSuchPortExposed
16+
from testcontainers.core.inspect import ContainerInspectInfo, _ignore_properties
1517
from testcontainers.core.waiting_utils import WaitStrategy
1618

17-
_IPT = TypeVar("_IPT")
1819
_WARNINGS = {"DOCKER_COMPOSE_GET_CONFIG": "get_config is experimental, see testcontainers/testcontainers-python#669"}
1920

2021
logger = getLogger(__name__)
2122

2223

23-
def _ignore_properties(cls: type[_IPT], dict_: Any) -> _IPT:
24-
"""omits extra fields like @JsonIgnoreProperties(ignoreUnknown = true)
25-
26-
https://gist.github.com/alexanderankin/2a4549ac03554a31bef6eaaf2eaf7fd5"""
27-
if isinstance(dict_, cls):
28-
return dict_
29-
if not is_dataclass(cls):
30-
raise TypeError(f"Expected a dataclass type, got {cls}")
31-
class_fields = {f.name for f in fields(cls)}
32-
filtered = {k: v for k, v in dict_.items() if k in class_fields}
33-
return cls(**filtered)
34-
35-
3624
@dataclass
3725
class PublishedPortModel:
3826
"""
@@ -45,10 +33,21 @@ class PublishedPortModel:
4533
Protocol: Optional[str] = None
4634

4735
def normalize(self) -> "PublishedPortModel":
48-
url_not_usable = system() == "Windows" and self.URL == "0.0.0.0"
49-
if url_not_usable:
36+
url = self.URL
37+
38+
# For SSH-based DOCKER_HOST, local addresses (0.0.0.0, 127.0.0.1, localhost, ::, ::1)
39+
# refer to the remote machine, not the local one.
40+
# Replace them with the actual remote hostname.
41+
ssh_host = get_docker_host_hostname()
42+
if ssh_host and url in ("0.0.0.0", "127.0.0.1", "localhost", "::", "::1"):
43+
url = ssh_host
44+
# On Windows, 0.0.0.0 is not usable — replace with 127.0.0.1
45+
elif system() == "Windows" and url == "0.0.0.0":
46+
url = "127.0.0.1"
47+
48+
if url != self.URL:
5049
self_dict = asdict(self)
51-
self_dict.update({"URL": "127.0.0.1"})
50+
self_dict.update({"URL": url})
5251
return PublishedPortModel(**self_dict)
5352
return self
5453

@@ -81,6 +80,7 @@ class ComposeContainer:
8180
ExitCode: Optional[int] = None
8281
Publishers: list[PublishedPortModel] = field(default_factory=list)
8382
_docker_compose: Optional["DockerCompose"] = field(default=None, init=False, repr=False)
83+
_cached_container_info: Optional[ContainerInspectInfo] = field(default=None, init=False, repr=False)
8484

8585
def __post_init__(self) -> None:
8686
if self.Publishers:
@@ -147,6 +147,28 @@ def reload(self) -> None:
147147
# each time through get_container(), but we need this method for compatibility
148148
pass
149149

150+
def get_container_info(self) -> Optional[ContainerInspectInfo]:
151+
"""Get container information via docker inspect (lazy loaded).
152+
153+
Returns:
154+
Container inspect information or None if container is not started.
155+
"""
156+
if self._cached_container_info is not None:
157+
return self._cached_container_info
158+
159+
if not self._docker_compose or not self.ID:
160+
return None
161+
162+
try:
163+
docker_client = self._docker_compose._get_docker_client()
164+
self._cached_container_info = docker_client.get_container_inspect_info(self.ID)
165+
166+
except Exception as e:
167+
logger.warning(f"Failed to get container info for {self.ID}: {e}")
168+
self._cached_container_info = None
169+
170+
return self._cached_container_info
171+
150172
@property
151173
def status(self) -> str:
152174
"""Get container status for compatibility with wait strategies."""
@@ -221,6 +243,7 @@ class DockerCompose:
221243
quiet_pull: bool = False
222244
quiet_build: bool = False
223245
_wait_strategies: Optional[dict[str, Any]] = field(default=None, init=False, repr=False)
246+
_docker_client: Optional[DockerClient] = field(default=None, init=False, repr=False)
224247

225248
def __post_init__(self) -> None:
226249
if isinstance(self.compose_file_name, str):
@@ -494,7 +517,7 @@ def get_service_port(
494517
495518
Returns
496519
-------
497-
str:
520+
int:
498521
The mapped port on the host
499522
"""
500523
normalize: PublishedPortModel = self.get_container(service_name).get_publisher(by_port=port).normalize()
@@ -585,3 +608,9 @@ def wait_for(self, url: str) -> "DockerCompose":
585608
with urlopen(url) as response:
586609
response.read()
587610
return self
611+
612+
def _get_docker_client(self) -> DockerClient:
613+
"""Get Docker client instance."""
614+
if self._docker_client is None:
615+
self._docker_client = DockerClient()
616+
return self._docker_client

core/testcontainers/core/container.py

Lines changed: 79 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
@@ -16,8 +19,10 @@
1619
from testcontainers.core.config import testcontainers_config as c
1720
from testcontainers.core.docker_client import DockerClient
1821
from testcontainers.core.exceptions import ContainerConnectException, ContainerStartException
22+
from testcontainers.core.inspect import ContainerInspectInfo
1923
from testcontainers.core.labels import LABEL_SESSION_ID, SESSION_ID
2024
from testcontainers.core.network import Network
25+
from testcontainers.core.transferable import Transferable, TransferSpec, build_transfer_tar
2126
from testcontainers.core.utils import is_arm, setup_logger
2227
from testcontainers.core.wait_strategies import LogMessageWaitStrategy
2328
from testcontainers.core.waiting_utils import WaitStrategy
@@ -69,6 +74,7 @@ def __init__(
6974
network: Optional[Network] = None,
7075
network_aliases: Optional[list[str]] = None,
7176
_wait_strategy: Optional[WaitStrategy] = None,
77+
transferables: Optional[list[TransferSpec]] = None,
7278
**kwargs: Any,
7379
) -> None:
7480
self.env = env or {}
@@ -82,6 +88,8 @@ def __init__(
8288
for vol in volumes:
8389
self.with_volume_mapping(*vol)
8490

91+
self.tmpfs: dict[str, str] = {}
92+
8593
self.image = image
8694
self._docker = DockerClient(**(docker_client_kw or {}))
8795
self._container: Optional[Container] = None
@@ -97,6 +105,12 @@ def __init__(
97105

98106
self._kwargs = kwargs
99107
self._wait_strategy: Optional[WaitStrategy] = _wait_strategy
108+
self._cached_container_info: Optional[ContainerInspectInfo] = None
109+
110+
self._transferable_specs: list[TransferSpec] = []
111+
if transferables:
112+
for t in transferables:
113+
self.with_copy_into_container(*t)
100114

101115
def with_env(self, key: str, value: str) -> Self:
102116
self.env[key] = value
@@ -198,13 +212,18 @@ def start(self) -> Self:
198212
ports=cast("dict[int, Optional[int]]", self.ports),
199213
name=self._name,
200214
volumes=self.volumes,
215+
tmpfs=self.tmpfs,
201216
**{**network_kwargs, **self._kwargs},
202217
)
203218

204219
if self._wait_strategy is not None:
205220
self._wait_strategy.wait_until_ready(self)
206221

207222
logger.info("Container started: %s", self._container.short_id)
223+
224+
for t in self._transferable_specs:
225+
self._transfer_into_container(*t)
226+
208227
return self
209228

210229
def stop(self, force: bool = True, delete_volume: bool = True) -> None:
@@ -270,6 +289,16 @@ def with_volume_mapping(self, host: Union[str, PathLike[str]], container: str, m
270289
self.volumes[str(host)] = mapping
271290
return self
272291

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

@@ -301,10 +330,60 @@ def exec(self, command: Union[str, list[str]]) -> ExecResult:
301330
raise ContainerStartException("Container should be started before executing a command")
302331
return self._container.exec_run(command)
303332

333+
def get_container_info(self) -> Optional[ContainerInspectInfo]:
334+
"""Get container information via docker inspect (lazy loaded).
335+
336+
Returns:
337+
Container inspect information or None if container is not started.
338+
"""
339+
if self._cached_container_info is not None:
340+
return self._cached_container_info
341+
342+
if not self._container:
343+
return None
344+
345+
try:
346+
self._cached_container_info = self.get_docker_client().get_container_inspect_info(self._container.id)
347+
348+
except Exception as e:
349+
logger.warning(f"Failed to get container info for {self._container.id}: {e}")
350+
self._cached_container_info = None
351+
352+
return self._cached_container_info
353+
304354
def _configure(self) -> None:
305355
# placeholder if subclasses want to define this and use the default start method
306356
pass
307357

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

309388
class Reaper:
310389
"""

0 commit comments

Comments
 (0)