Skip to content

Commit 259e0f9

Browse files
authored
Merge branch 'main' into dependabot/uv/urllib3-2.7.0
2 parents 78ae0be + 3cbd956 commit 259e0f9

18 files changed

Lines changed: 2749 additions & 2037 deletions

File tree

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

‎.github/dependabot.yml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
version: 2
2+
updates:
3+
- package-ecosystem: "uv"
4+
directory: "/"
5+
schedule:
6+
interval: "weekly"

‎CHANGELOG.md‎

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

3+
## [4.15.0-rc3](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.15.0-rc2...testcontainers-v4.15.0-rc3) (2026-06-03)
4+
5+
6+
### Features
7+
8+
* **core:** add Podman compatibility ([#1028](https://github.com/testcontainers/testcontainers-python/issues/1028)) ([37093ab](https://github.com/testcontainers/testcontainers-python/commit/37093ab8e1a86d1aecbad915f1af20934861ac35))
9+
* **core:** auto-detect DOCKER_HOST from current docker context ([#1026](https://github.com/testcontainers/testcontainers-python/issues/1026)) ([d4b8ff6](https://github.com/testcontainers/testcontainers-python/commit/d4b8ff66197e7eb57708c9579fa1fb41d6988f5e)), closes [#1025](https://github.com/testcontainers/testcontainers-python/issues/1025)
10+
11+
12+
### Bug Fixes
13+
14+
* **core:** add container logs to TimeoutError when wait strategy times out ([#1007](https://github.com/testcontainers/testcontainers-python/issues/1007)) ([1fadb40](https://github.com/testcontainers/testcontainers-python/commit/1fadb40c456c3ccd4f13b1b82cd19c3ab22c2687))
15+
* **core:** close Reaper socket at interpreter exit to prevent ResourceWarning ([#1017](https://github.com/testcontainers/testcontainers-python/issues/1017)) ([d3a1517](https://github.com/testcontainers/testcontainers-python/commit/d3a1517f2c5c2e2a48a4c051b2203b80d9141759))
16+
* **cosmosdb:** replace distutils.strtobool removed in Python 3.12 ([#1018](https://github.com/testcontainers/testcontainers-python/issues/1018)) ([3e487f4](https://github.com/testcontainers/testcontainers-python/commit/3e487f4bab679204820826cfcbc282cb8fc60008))
17+
* **mongodb:** Use wait strategy instead of deprecated wait_for_logs ([#943](https://github.com/testcontainers/testcontainers-python/issues/943)) ([c8118e7](https://github.com/testcontainers/testcontainers-python/commit/c8118e7c9832743486115221fd8253bdc8c2a5ec))
18+
* **mssql:** use `ExecWaitStrategy` instead of deprecated `wait_container_is_ready` ([#1016](https://github.com/testcontainers/testcontainers-python/issues/1016)) ([4c53b89](https://github.com/testcontainers/testcontainers-python/commit/4c53b89a1faad9a592d2bb49123ef3e6bf33fdb9)), closes [#874](https://github.com/testcontainers/testcontainers-python/issues/874)
19+
* **neo4j:** Swap out use of depricated wait utils for wait_strategies ([#1012](https://github.com/testcontainers/testcontainers-python/issues/1012)) ([6deaf3b](https://github.com/testcontainers/testcontainers-python/commit/6deaf3b27e5f8d54ff9ae404dbd092e8014df079))
20+
321
## [4.15.0-rc2](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.15.0-rc.1...testcontainers-v4.15.0-rc2) (2026-04-30)
422

523

‎core/testcontainers/compose/compose.py‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import shutil
12
import sys
23
from dataclasses import asdict, dataclass, field
34
from functools import cached_property
@@ -11,7 +12,7 @@
1112
from types import TracebackType
1213
from typing import Any, Callable, Literal, Optional, TypeVar, Union, cast
1314

14-
from testcontainers.core.docker_client import DockerClient, get_docker_host_hostname
15+
from testcontainers.core.docker_client import DockerClient, get_docker_host_hostname, is_podman
1516
from testcontainers.core.exceptions import ContainerIsNotRunning, NoSuchPortExposed
1617
from testcontainers.core.inspect import ContainerInspectInfo, _ignore_properties
1718
from testcontainers.core.waiting_utils import WaitStrategy
@@ -21,6 +22,20 @@
2122
logger = getLogger(__name__)
2223

2324

25+
def _default_compose_binary() -> str:
26+
"""Return the binary used to drive ``compose`` subcommands.
27+
28+
Prefers ``docker`` when available; otherwise falls back to ``podman``
29+
when the daemon is detected as podman. This means a pure-podman host
30+
without the ``podman-docker`` shim works out of the box.
31+
"""
32+
if shutil.which("docker"):
33+
return "docker"
34+
if is_podman() and shutil.which("podman"):
35+
return "podman"
36+
return "docker"
37+
38+
2439
@dataclass
2540
class PublishedPortModel:
2641
"""
@@ -38,8 +53,9 @@ def normalize(self) -> "PublishedPortModel":
3853
# For SSH-based DOCKER_HOST, local addresses (0.0.0.0, 127.0.0.1, localhost, ::, ::1)
3954
# refer to the remote machine, not the local one.
4055
# Replace them with the actual remote hostname.
56+
# Podman may also return empty string or None for the URL.
4157
ssh_host = get_docker_host_hostname()
42-
if ssh_host and url in ("0.0.0.0", "127.0.0.1", "localhost", "::", "::1"):
58+
if ssh_host and (not url or url in ("0.0.0.0", "127.0.0.1", "localhost", "::", "::1")):
4359
url = ssh_host
4460
# On Windows, 0.0.0.0 is not usable — replace with 127.0.0.1
4561
elif system() == "Windows" and url == "0.0.0.0":
@@ -275,9 +291,8 @@ def docker_compose_command(self) -> list[str]:
275291

276292
@cached_property
277293
def compose_command_property(self) -> list[str]:
278-
docker_compose_cmd = (
279-
[self.docker_command_path, "compose"] if self.docker_command_path else ["docker", "compose"]
280-
)
294+
binary = self.docker_command_path or _default_compose_binary()
295+
docker_compose_cmd = [binary, "compose"]
281296
if self.compose_file_name:
282297
for file in self.compose_file_name:
283298
docker_compose_cmd += ["-f", file]

‎core/testcontainers/core/docker_client.py‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar, Union, cast
2323

2424
import docker
25+
from docker.context import ContextAPI
2526
from docker.models.containers import Container, ContainerCollection
2627
from docker.models.images import Image, ImageCollection
2728
from typing_extensions import ParamSpec
@@ -320,12 +321,33 @@ def get_container_inspect_info(self, container_id: str) -> "ContainerInspectInfo
320321

321322

322323
def get_docker_host() -> Optional[str]:
323-
host = c.tc_properties_get_tc_host() or os.getenv("DOCKER_HOST")
324+
host = c.tc_properties_get_tc_host() or os.getenv("DOCKER_HOST") or _get_docker_host_from_context()
324325
if host:
325326
return _sanitize_docker_host(host)
326327
return None
327328

328329

330+
def _get_docker_host_from_context() -> Optional[str]:
331+
"""
332+
Look up the docker host from the current docker context (e.g. as set by``docker context use``).
333+
This allows users with a remote docker host configured via docker contexts to use testcontainers
334+
without having to additionally export ``DOCKER_HOST``.
335+
"""
336+
try:
337+
context = ContextAPI.get_current_context()
338+
except Exception as e:
339+
LOGGER.debug(f"failed to read current docker context: {e}")
340+
return None
341+
if context is None:
342+
return None
343+
host = context.Host
344+
# The default context points at the local unix socket / named pipe; let
345+
# docker-py fall back to its own defaults in that case.
346+
if not host or context.Name == "default":
347+
return None
348+
return cast("str", host)
349+
350+
329351
def get_docker_host_hostname() -> Optional[str]:
330352
"""Extract the remote hostname from an SSH-based DOCKER_HOST.
331353
@@ -344,6 +366,30 @@ def is_ssh_docker_host() -> bool:
344366
return get_docker_host_hostname() is not None
345367

346368

369+
@ft.lru_cache(maxsize=1)
370+
def is_podman() -> bool:
371+
"""Detect whether the configured Docker daemon is actually Podman.
372+
373+
The result is cached for the lifetime of the process: detection requires a
374+
daemon round-trip, and this helper is invoked at test-collection time via
375+
``pytest.mark.skipif`` decorators.
376+
"""
377+
try:
378+
# Use docker.from_env() directly rather than DockerClient() so we avoid
379+
# the constructor's side effects (DOCKER_HOST mutation, registry login).
380+
version = docker.from_env().version()
381+
except Exception as e:
382+
LOGGER.debug(f"is_podman: failed to query daemon version: {e}")
383+
return False
384+
385+
# Prefer the top-level Platform.Name field (matches testcontainers-go).
386+
platform_name = (version.get("Platform") or {}).get("Name", "")
387+
if "podman" in platform_name.lower():
388+
return True
389+
# Fall back to scanning the Components array for older podman versions.
390+
return any("podman" in comp.get("Name", "").lower() for comp in version.get("Components") or [])
391+
392+
347393
def _sanitize_docker_host(docker_host: str) -> str:
348394
"""
349395
Sanitize the DOCKER_HOST value for compatibility with the Docker SDK.

‎core/tests/test_compose.py‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,16 @@
1010
from pytest_mock import MockerFixture
1111

1212
from testcontainers.compose import DockerCompose, ComposeContainer
13+
from testcontainers.core.docker_client import is_podman
1314
from testcontainers.core.exceptions import ContainerIsNotRunning, NoSuchPortExposed
1415

1516
FIXTURES = Path(__file__).parent.joinpath("compose_fixtures")
1617

18+
_skip_if_podman_port_range = pytest.mark.skipif(
19+
is_podman(),
20+
reason="Podman does not support published port ranges (e.g. '5000-5999')",
21+
)
22+
1723

1824
def test_compose_no_file_name():
1925
basic = DockerCompose(context=FIXTURES / "basic")
@@ -189,6 +195,7 @@ def test_compose_ports():
189195

190196

191197
# noinspection HttpUrlsUsage
198+
@_skip_if_podman_port_range
192199
def test_compose_multiple_containers_and_ports():
193200
"""test for the logic encapsulated in 'one' function
194201
@@ -286,6 +293,7 @@ def test_exec_in_container():
286293

287294

288295
# noinspection HttpUrlsUsage
296+
@_skip_if_podman_port_range
289297
def test_exec_in_container_multiple():
290298
"""same as above, except we exec into a particular service"""
291299
multiple = DockerCompose(context=FIXTURES / "port_multiple")
@@ -390,6 +398,8 @@ def test_compose_profile_support(profiles: Optional[list[str]], running: list[st
390398
pytest.param("ssh://user@10.0.0.5", "0.0.0.0", "10.0.0.5", id="ssh_replaces_wildcard"),
391399
pytest.param("ssh://user@10.0.0.5", "127.0.0.1", "10.0.0.5", id="ssh_replaces_loopback"),
392400
pytest.param("ssh://user@10.0.0.5", "::", "10.0.0.5", id="ssh_replaces_ipv6_any"),
401+
pytest.param("ssh://user@10.0.0.5", "", "10.0.0.5", id="ssh_replaces_empty"),
402+
pytest.param("ssh://user@10.0.0.5", None, "10.0.0.5", id="ssh_replaces_none"),
393403
pytest.param("tcp://localhost:2375", "0.0.0.0", "0.0.0.0", id="non_ssh_keeps_original"),
394404
],
395405
)
@@ -410,6 +420,33 @@ def test_compose_normalize_rewrites_local_url_for_ssh_docker_host(
410420
assert result.PublishedPort == 9999
411421

412422

423+
@pytest.mark.parametrize(
424+
"docker_on_path, podman_on_path, podman_detected, expected",
425+
[
426+
pytest.param(True, True, True, "docker", id="docker_wins_over_podman"),
427+
pytest.param(False, True, True, "podman", id="podman_when_no_docker_and_podman_daemon"),
428+
pytest.param(False, False, True, "docker", id="fallback_to_docker_when_no_podman_binary"),
429+
],
430+
)
431+
def test_default_compose_binary(
432+
monkeypatch: pytest.MonkeyPatch,
433+
docker_on_path: bool,
434+
podman_on_path: bool,
435+
podman_detected: bool,
436+
expected: str,
437+
) -> None:
438+
from testcontainers.compose import compose as compose_module
439+
440+
paths = {
441+
"docker": "/usr/bin/docker" if docker_on_path else None,
442+
"podman": "/usr/bin/podman" if podman_on_path else None,
443+
}
444+
monkeypatch.setattr("testcontainers.compose.compose.shutil.which", lambda name: paths.get(name))
445+
monkeypatch.setattr(compose_module, "is_podman", lambda: podman_detected)
446+
447+
assert compose_module._default_compose_binary() == expected
448+
449+
413450
def test_container_info():
414451
"""Test get_container_info functionality"""
415452
basic = DockerCompose(context=FIXTURES / "basic")

‎core/tests/test_core_ports.py‎

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,31 @@
44

55
from docker.errors import APIError
66

7+
from testcontainers.core.docker_client import is_podman
8+
79

810
@pytest.mark.parametrize(
911
"container_port, host_port",
1012
[
1113
("8080", "8080"),
12-
("8125/udp", "8125/udp"),
13-
("8092/udp", "8092/udp"),
14-
("9000/tcp", "9000/tcp"),
15-
("8080", "8080/udp"),
14+
pytest.param(
15+
"8125/udp",
16+
"8125/udp",
17+
marks=pytest.mark.skipif(is_podman(), reason="Podman rejects protocol in host_port"),
18+
),
19+
pytest.param(
20+
"8092/udp",
21+
"8092/udp",
22+
marks=pytest.mark.skipif(is_podman(), reason="Podman rejects protocol in host_port"),
23+
),
24+
pytest.param(
25+
"9000/tcp",
26+
"9000/tcp",
27+
marks=pytest.mark.skipif(is_podman(), reason="Podman rejects protocol in host_port"),
28+
),
29+
pytest.param(
30+
"8080", "8080/udp", marks=pytest.mark.skipif(is_podman(), reason="Podman rejects protocol in host_port")
31+
),
1632
(8080, 8080),
1733
(9000, None),
1834
("9009", None),
@@ -42,7 +58,18 @@ def test_docker_container_with_bind_ports(container_port: Union[str, int], host_
4258
expected = {container_port: [{"HostIp": "", "HostPort": host_port}]}
4359

4460
# compare PortBindings to expected output
45-
assert client.containers.get(container_id).attrs["HostConfig"]["PortBindings"] == expected
61+
actual = client.containers.get(container_id).attrs["HostConfig"]["PortBindings"]
62+
if is_podman():
63+
# Normalize Podman differences:
64+
# - HostIp '0.0.0.0' vs '' (both mean all interfaces)
65+
# - Empty host_port: Podman stores the assigned port, Docker stores ''
66+
for bindings in actual.values():
67+
for binding in bindings:
68+
if binding.get("HostIp") == "0.0.0.0":
69+
binding["HostIp"] = ""
70+
if not host_port and binding.get("HostPort", "").isdigit():
71+
binding["HostPort"] = ""
72+
assert actual == expected
4673
container.stop()
4774

4875

‎core/tests/test_core_registry.py‎

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,16 @@
2222

2323
from testcontainers.registry import DockerRegistryContainer
2424
from testcontainers.core.utils import is_mac
25+
from testcontainers.core.docker_client import is_podman
2526

2627

27-
@pytest.mark.skipif(
28-
is_mac(),
29-
reason="Docker Desktop on macOS does not support insecure private registries without daemon reconfiguration",
30-
)
31-
@pytest.mark.skipif(
32-
is_ssh_docker_host(),
33-
reason="Remote Docker via SSH requires HTTPS for non-localhost registries; insecure HTTP registries are not accessible",
28+
_skip_insecure_registry = pytest.mark.skipif(
29+
is_mac() or is_podman() or is_ssh_docker_host(),
30+
reason="Insecure HTTP registries are not supported without daemon reconfiguration on macOS, Podman, or SSH-based Docker hosts",
3431
)
32+
33+
34+
@_skip_insecure_registry
3535
def test_missing_on_private_registry(monkeypatch):
3636
username = "user"
3737
password = "pass"
@@ -53,14 +53,7 @@ def test_missing_on_private_registry(monkeypatch):
5353
wait_for_logs(test_container, "Hello from Docker!")
5454

5555

56-
@pytest.mark.skipif(
57-
is_mac(),
58-
reason="Docker Desktop on macOS does not support local insecure registries over HTTP without modifying daemon settings",
59-
)
60-
@pytest.mark.skipif(
61-
is_ssh_docker_host(),
62-
reason="Remote Docker via SSH requires HTTPS for non-localhost registries; insecure HTTP registries are not accessible",
63-
)
56+
@_skip_insecure_registry
6457
@pytest.mark.parametrize(
6558
"image,tag,username,password,expected_output",
6659
[

0 commit comments

Comments
 (0)