Skip to content

Commit 6659ff5

Browse files
Merge branch 'main' into with-copy-to
2 parents 8f32d9b + 23aca0b commit 6659ff5

34 files changed

Lines changed: 1875 additions & 234 deletions

File tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
".": "4.14.2"
2+
".": "4.15.0-rc.1"
33
}

CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,31 @@
11
# Changelog
22

3+
## [4.15.0-rc.1](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.14.2...testcontainers-v4.15.0-rc.1) (2026-04-07)
4+
5+
6+
### Features
7+
8+
* **compose:** add structured container inspect information ([#897](https://github.com/testcontainers/testcontainers-python/issues/897)) ([58459a1](https://github.com/testcontainers/testcontainers-python/commit/58459a13a1523c5dec8b21b0e16ae1afdce48156))
9+
* **core:** support SSH-based DOCKER_HOST ([#993](https://github.com/testcontainers/testcontainers-python/issues/993)) ([d48115d](https://github.com/testcontainers/testcontainers-python/commit/d48115def127644964d4d2b09a38e3f4492cc43c))
10+
* **generic:** Reintroducing the generic SQL module ([#892](https://github.com/testcontainers/testcontainers-python/issues/892)) ([2ca2321](https://github.com/testcontainers/testcontainers-python/commit/2ca2321ada12e09d491280c8ec855bf8511de7c2))
11+
* **keycloak:** support for relative path and management relative path ([#982](https://github.com/testcontainers/testcontainers-python/issues/982)) ([898faf6](https://github.com/testcontainers/testcontainers-python/commit/898faf6a5955698958be6e8cfd32b87323d62a44))
12+
* **mqtt:** MosquittoContainer: Add version 2.1.2 ([#978](https://github.com/testcontainers/testcontainers-python/issues/978)) ([af382f7](https://github.com/testcontainers/testcontainers-python/commit/af382f74e82bdcb14eac3f4e04a83432ae9beeba))
13+
14+
15+
### Bug Fixes
16+
17+
* **azurite:** make visible to type checkers ([#927](https://github.com/testcontainers/testcontainers-python/issues/927)) ([baa5668](https://github.com/testcontainers/testcontainers-python/commit/baa566814b22fa922094a625ff92037cbe8bd93f))
18+
* **clickhouse:** add `HttpWaitStrategy` instead of deprecated `wait_container_is_ready` ([#962](https://github.com/testcontainers/testcontainers-python/issues/962)) ([8034541](https://github.com/testcontainers/testcontainers-python/commit/803454147c03418b7b06601d251eb491a2cd79cf))
19+
* **compose:** return type in get_service_port docstring ([#939](https://github.com/testcontainers/testcontainers-python/issues/939)) ([fed65fe](https://github.com/testcontainers/testcontainers-python/commit/fed65fe14507020007c115c535364c90d4bbdde9))
20+
* **core:** Refactor copy file ([#996](https://github.com/testcontainers/testcontainers-python/issues/996)) ([0e0bb24](https://github.com/testcontainers/testcontainers-python/commit/0e0bb24a2bddfd8a03bebdfc3b9ff8cf8c78092b))
21+
* **core:** wait for ryuk more reliably, improve tests: long_running, filter logs ([#984](https://github.com/testcontainers/testcontainers-python/issues/984)) ([b12ae13](https://github.com/testcontainers/testcontainers-python/commit/b12ae13e589a4ffe326c162a38df56eb30521d69))
22+
* **generic:** Migrate ServerContainer from deprecated decorator to HttpWaitStrategy ([#971](https://github.com/testcontainers/testcontainers-python/issues/971)) ([460b0d8](https://github.com/testcontainers/testcontainers-python/commit/460b0d8a09635068815ea8c5c5a4e4cc1e3dfea7))
23+
* **kafka:** Use wait strategy instead of deprecated wait_for_logs ([#903](https://github.com/testcontainers/testcontainers-python/issues/903)) ([87332c1](https://github.com/testcontainers/testcontainers-python/commit/87332c1332a30b673aac919b48e296e21f2c1baf))
24+
* **postgres:** add py.typed marker to postgres module ([#849](https://github.com/testcontainers/testcontainers-python/issues/849)) ([c8a5bbd](https://github.com/testcontainers/testcontainers-python/commit/c8a5bbdbab137e6dc5af9a7224e65972665ec84d))
25+
* **qdrant:** migrate Qdrant from deprecated decorator. ([#963](https://github.com/testcontainers/testcontainers-python/issues/963)) ([407f798](https://github.com/testcontainers/testcontainers-python/commit/407f79825be97865010dc0119cdfe3498a609a08))
26+
* **redis:** Use wait strategy instead of deprecated decorator ([#914](https://github.com/testcontainers/testcontainers-python/issues/914)) ([e25713a](https://github.com/testcontainers/testcontainers-python/commit/e25713a300eda6a14973d2465590d2318dcc375d))
27+
* **sftp:** Avoid using wait_for_logs in module. ([#995](https://github.com/testcontainers/testcontainers-python/issues/995)) ([83157eb](https://github.com/testcontainers/testcontainers-python/commit/83157eb4acd931949cfec3d2a84db0a61685e739))
28+
329
## [4.14.2](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.14.1...testcontainers-v4.14.2) (2026-03-18)
430

531

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/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: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from testcontainers.core.config import testcontainers_config as c
2020
from testcontainers.core.docker_client import DockerClient
2121
from testcontainers.core.exceptions import ContainerConnectException, ContainerStartException
22+
from testcontainers.core.inspect import ContainerInspectInfo
2223
from testcontainers.core.labels import LABEL_SESSION_ID, SESSION_ID
2324
from testcontainers.core.network import Network
2425
from testcontainers.core.transferable import Transferable, TransferSpec, build_transfer_tar
@@ -104,6 +105,7 @@ def __init__(
104105

105106
self._kwargs = kwargs
106107
self._wait_strategy: Optional[WaitStrategy] = _wait_strategy
108+
self._cached_container_info: Optional[ContainerInspectInfo] = None
107109

108110
self._transferable_specs: list[TransferSpec] = []
109111
if transferables:
@@ -336,6 +338,27 @@ def wait(self) -> int:
336338
result = self._container.wait()
337339
return int(result["StatusCode"])
338340

341+
def get_container_info(self) -> Optional[ContainerInspectInfo]:
342+
"""Get container information via docker inspect (lazy loaded).
343+
344+
Returns:
345+
Container inspect information or None if container is not started.
346+
"""
347+
if self._cached_container_info is not None:
348+
return self._cached_container_info
349+
350+
if not self._container:
351+
return None
352+
353+
try:
354+
self._cached_container_info = self.get_docker_client().get_container_inspect_info(self._container.id)
355+
356+
except Exception as e:
357+
logger.warning(f"Failed to get container info for {self._container.id}: {e}")
358+
self._cached_container_info = None
359+
360+
return self._cached_container_info
361+
339362
def _configure(self) -> None:
340363
# placeholder if subclasses want to define this and use the default start method
341364
pass

core/testcontainers/core/docker_client.py

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from testcontainers.core.auth import DockerAuthInfo, parse_docker_auth_config
3131
from testcontainers.core.config import ConnectionMode
3232
from testcontainers.core.config import testcontainers_config as c
33+
from testcontainers.core.inspect import ContainerInspectInfo
3334
from testcontainers.core.labels import SESSION_ID, create_labels
3435

3536
if TYPE_CHECKING:
@@ -68,9 +69,12 @@ def __init__(self, **kwargs: Any) -> None:
6869
if docker_host:
6970
LOGGER.info(f"using host {docker_host}")
7071
os.environ["DOCKER_HOST"] = docker_host
71-
self.client = docker.from_env(**kwargs)
72-
else:
73-
self.client = docker.from_env(**kwargs)
72+
# Use shell-based SSH client instead of paramiko to avoid conflicts with pytest stdin capture
73+
# (paramiko's invoke library fails when reading from captured stdin).
74+
if docker_host.startswith("ssh://"):
75+
kwargs.setdefault("use_ssh_client", True)
76+
77+
self.client = docker.from_env(**kwargs)
7478
self.client.api.headers["x-tc-sid"] = SESSION_ID
7579
self.client.api.headers["User-Agent"] = "tc-python/" + importlib.metadata.version("testcontainers")
7680

@@ -271,6 +275,14 @@ def host(self) -> str:
271275
host = c.tc_host_override
272276
if host:
273277
return host
278+
279+
# For SSH-based connections, the Docker SDK rewrites base_url to
280+
# "http+docker://ssh" which loses the original hostname.
281+
# Extract it from the original DOCKER_HOST instead.
282+
ssh_host = get_docker_host_hostname()
283+
if ssh_host:
284+
return ssh_host
285+
274286
try:
275287
url = urllib.parse.urlparse(self.client.api.base_url)
276288
except ValueError:
@@ -301,9 +313,59 @@ def client_networks_create(self, name: str, param: dict[str, Any]) -> "DockerNet
301313
labels = create_labels("", param.get("labels"))
302314
return self.client.networks.create(name, **{**param, "labels": labels})
303315

316+
def get_container_inspect_info(self, container_id: str) -> "ContainerInspectInfo":
317+
"""Get container inspect information with fresh data."""
318+
container = self.client.containers.get(container_id)
319+
return ContainerInspectInfo.from_dict(container.attrs)
320+
304321

305322
def get_docker_host() -> Optional[str]:
306-
return c.tc_properties_get_tc_host() or os.getenv("DOCKER_HOST")
323+
host = c.tc_properties_get_tc_host() or os.getenv("DOCKER_HOST")
324+
if host:
325+
return _sanitize_docker_host(host)
326+
return None
327+
328+
329+
def get_docker_host_hostname() -> Optional[str]:
330+
"""Extract the remote hostname from an SSH-based DOCKER_HOST.
331+
332+
Returns the hostname (e.g. '192.168.1.42') when DOCKER_HOST is an ssh:// URL, or None otherwise.
333+
"""
334+
docker_host = get_docker_host()
335+
if docker_host and docker_host.startswith("ssh://"):
336+
parsed = urllib.parse.urlparse(docker_host)
337+
if parsed.hostname:
338+
return parsed.hostname
339+
return None
340+
341+
342+
def is_ssh_docker_host() -> bool:
343+
"""Check if the current DOCKER_HOST is an SSH-based connection."""
344+
return get_docker_host_hostname() is not None
345+
346+
347+
def _sanitize_docker_host(docker_host: str) -> str:
348+
"""
349+
Sanitize the DOCKER_HOST value for compatibility with the Docker SDK.
350+
351+
Strips path components from ``ssh://`` URLs because the Docker SDK
352+
does not support them. A lone trailing ``/`` is treated as
353+
equivalent to no path and silently normalised without a warning.
354+
"""
355+
if docker_host.startswith("ssh://"):
356+
parsed = urllib.parse.urlparse(docker_host)
357+
if parsed.path and parsed.path != "/":
358+
sanitized = urllib.parse.urlunparse(parsed._replace(path=""))
359+
LOGGER.warning(
360+
"Stripped path from SSH DOCKER_HOST (unsupported by Docker SDK): %s -> %s",
361+
docker_host,
362+
sanitized,
363+
)
364+
return sanitized
365+
if parsed.path == "/":
366+
# Trailing slash is harmless — strip quietly.
367+
return urllib.parse.urlunparse(parsed._replace(path=""))
368+
return docker_host
307369

308370

309371
def get_docker_auth_config() -> Optional[str]:

0 commit comments

Comments
 (0)