Skip to content

Commit e365dc1

Browse files
authored
Merge branch 'main' into fix_test_core_registry
2 parents aefa89b + 6b11268 commit e365dc1

File tree

20 files changed

+358
-37
lines changed

20 files changed

+358
-37
lines changed

.github/workflows/ci-core.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Contrinuous Integration for the core package
1+
# Continuous Integration for the core package
22

33
name: core
44

@@ -25,6 +25,8 @@ jobs:
2525
run: poetry install --all-extras
2626
- name: Run twine check
2727
run: poetry build && poetry run twine check dist/*.tar.gz
28+
- name: Set up Docker
29+
uses: docker/setup-docker-action@v4
2830
- name: Run tests
2931
run: make core/tests
3032
- name: Rename coverage file

core/README.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ Testcontainers Core
1818

1919
.. autoclass:: testcontainers.core.generic.DbContainer
2020

21+
.. autoclass:: testcontainers.core.network.Network
22+
2123
.. raw:: html
2224

2325
<hr>

core/testcontainers/core/container.py

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,20 @@ class DockerContainer:
2828
"""
2929
Basic container object to spin up Docker instances.
3030
31+
Args:
32+
image: The name of the image to start.
33+
docker_client_kw: Dictionary with arguments that will be passed to the
34+
docker.DockerClient init.
35+
command: Optional execution command for the container.
36+
name: Optional name for the container.
37+
ports: Ports to be exposed by the container. The port number will be
38+
automatically assigned on the host, use
39+
:code:`get_exposed_port(PORT)` method to get the port number on the host.
40+
volumes: Volumes to mount into the container. Each entry should be a tuple with
41+
three values: host path, container path and. mode (default 'ro').
42+
network: Optional network to connect the container to.
43+
network_aliases: Optional list of aliases for the container in the network.
44+
3145
.. doctest::
3246
3347
>>> from testcontainers.core.container import DockerContainer
@@ -41,18 +55,40 @@ def __init__(
4155
self,
4256
image: str,
4357
docker_client_kw: Optional[dict] = None,
58+
command: Optional[str] = None,
59+
env: Optional[dict[str, str]] = None,
60+
name: Optional[str] = None,
61+
ports: Optional[list[int]] = None,
62+
volumes: Optional[list[tuple[str, str, str]]] = None,
63+
network: Optional[Network] = None,
64+
network_aliases: Optional[list[str]] = None,
4465
**kwargs,
4566
) -> None:
46-
self.env = {}
67+
self.env = env or {}
68+
4769
self.ports = {}
70+
if ports:
71+
self.with_exposed_ports(*ports)
72+
4873
self.volumes = {}
74+
if volumes:
75+
for vol in volumes:
76+
self.with_volume_mapping(*vol)
77+
4978
self.image = image
5079
self._docker = DockerClient(**(docker_client_kw or {}))
5180
self._container = None
52-
self._command = None
53-
self._name = None
81+
self._command = command
82+
self._name = name
83+
5484
self._network: Optional[Network] = None
85+
if network is not None:
86+
self.with_network(network)
87+
5588
self._network_aliases: Optional[list[str]] = None
89+
if network_aliases:
90+
self.with_network_aliases(*network_aliases)
91+
5692
self._kwargs = kwargs
5793

5894
def with_env(self, key: str, value: str) -> Self:

core/testcontainers/core/docker_client.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ def find_host_network(self) -> Optional[str]:
151151
except ipaddress.AddressValueError:
152152
continue
153153
if docker_host in subnet:
154-
return cast(str, network.name)
154+
return cast("str", network.name)
155155
except (ipaddress.AddressValueError, OSError):
156156
pass
157157
return None
@@ -163,7 +163,7 @@ def port(self, container_id: str, port: int) -> str:
163163
port_mappings = self.client.api.port(container_id, port)
164164
if not port_mappings:
165165
raise ConnectionError(f"Port mapping for container {container_id} and port {port} is not available")
166-
return cast(str, port_mappings[0]["HostPort"])
166+
return cast("str", port_mappings[0]["HostPort"])
167167

168168
def get_container(self, container_id: str) -> dict[str, Any]:
169169
"""
@@ -172,7 +172,7 @@ def get_container(self, container_id: str) -> dict[str, Any]:
172172
containers = self.client.api.containers(filters={"id": container_id})
173173
if not containers:
174174
raise RuntimeError(f"Could not get container with id {container_id}")
175-
return cast(dict[str, Any], containers[0])
175+
return cast("dict[str, Any]", containers[0])
176176

177177
def bridge_ip(self, container_id: str) -> str:
178178
"""
@@ -241,7 +241,7 @@ def host(self) -> str:
241241
hostname = url.hostname
242242
if not hostname or (hostname == "localnpipe" and utils.is_windows()):
243243
return "localhost"
244-
return cast(str, url.hostname)
244+
return cast("str", url.hostname)
245245
if utils.inside_container() and ("unix" in url.scheme or "npipe" in url.scheme):
246246
ip_address = utils.default_gateway_ip()
247247
if ip_address:
@@ -257,7 +257,7 @@ def login(self, auth_config: DockerAuthInfo) -> None:
257257

258258
def client_networks_create(self, name: str, param: dict[str, Any]) -> dict[str, Any]:
259259
labels = create_labels("", param.get("labels"))
260-
return cast(dict[str, Any], self.client.networks.create(name, **{**param, "labels": labels}))
260+
return cast("dict[str, Any]", self.client.networks.create(name, **{**param, "labels": labels}))
261261

262262

263263
def get_docker_host() -> Optional[str]:

core/tests/compose_fixtures/port_multiple/compose.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ services:
66
- '81'
77
- '82'
88
- target: 80
9+
published: "5000-5999"
910
host_ip: 127.0.0.1
1011
protocol: tcp
1112
command:
@@ -18,6 +19,7 @@ services:
1819
init: true
1920
ports:
2021
- target: 80
22+
published: "5000-5999"
2123
host_ip: 127.0.0.1
2224
protocol: tcp
2325
command:

core/tests/test_container.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,24 @@ def test_get_exposed_port_original(container: DockerContainer, monkeypatch: pyte
7575
monkeypatch.setattr(client, "get_connection_mode", lambda: ConnectionMode.bridge_ip)
7676

7777
assert container.get_exposed_port(8080) == 8080
78+
79+
80+
@pytest.mark.parametrize(
81+
"init_attr,init_value,class_attr,stored_value",
82+
[
83+
("command", "ps", "_command", "ps"),
84+
("env", {"e1": "v1"}, "env", {"e1": "v1"}),
85+
("name", "foo-bar", "_name", "foo-bar"),
86+
("ports", [22, 80], "ports", {22: None, 80: None}),
87+
(
88+
"volumes",
89+
[("/tmp", "/tmp2", "ro")],
90+
"volumes",
91+
{"/tmp": {"bind": "/tmp2", "mode": "ro"}},
92+
),
93+
],
94+
)
95+
def test_attribute(init_attr, init_value, class_attr, stored_value):
96+
"""Test that the attributes set through the __init__ function are properly stored."""
97+
with DockerContainer("ubuntu", **{init_attr: init_value}) as container:
98+
assert getattr(container, class_attr) == stored_value

core/tests/test_core_registry.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,13 @@
1818
from testcontainers.core.waiting_utils import wait_for_logs
1919

2020
from testcontainers.registry import DockerRegistryContainer
21+
from testcontainers.core.utils import is_mac
2122

2223

24+
@pytest.mark.skipif(
25+
is_mac(),
26+
reason="Docker Desktop on macOS does not support insecure private registries without daemon reconfiguration",
27+
)
2328
def test_missing_on_private_registry(monkeypatch):
2429
username = "user"
2530
password = "pass"
@@ -41,6 +46,10 @@ def test_missing_on_private_registry(monkeypatch):
4146
wait_for_logs(test_container, "Hello from Docker!")
4247

4348

49+
@pytest.mark.skipif(
50+
is_mac(),
51+
reason="Docker Desktop on macOS does not support local insecure registries over HTTP without modifying daemon settings",
52+
)
4453
@pytest.mark.parametrize(
4554
"image,tag,username,password,expected_output",
4655
[

core/tests/test_docker_in_docker.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from testcontainers.core.container import DockerContainer
1616
from testcontainers.core.docker_client import DockerClient, LOGGER
1717
from testcontainers.core.utils import inside_container
18+
from testcontainers.core.utils import is_mac
1819
from testcontainers.core.waiting_utils import wait_for_logs
1920

2021

@@ -36,6 +37,7 @@ def _wait_for_dind_return_ip(client, dind):
3637
return docker_host_ip
3738

3839

40+
@pytest.mark.skipif(is_mac(), reason="Docker socket forwarding (socat) is unsupported on Docker Desktop for macOS")
3941
def test_wait_for_logs_docker_in_docker():
4042
# real dind isn't possible (AFAIK) in CI
4143
# forwarding the socket to a container port is at least somewhat the same
@@ -64,6 +66,9 @@ def test_wait_for_logs_docker_in_docker():
6466
not_really_dind.remove()
6567

6668

69+
@pytest.mark.skipif(
70+
is_mac(), reason="Bridge networking and Docker socket forwarding are not supported on Docker Desktop for macOS"
71+
)
6772
def test_dind_inherits_network():
6873
client = DockerClient()
6974
try:
@@ -158,6 +163,9 @@ def test_find_host_network_in_dood() -> None:
158163
assert DockerClient().find_host_network() == os.environ[EXPECTED_NETWORK_VAR]
159164

160165

166+
@pytest.mark.skipif(
167+
is_mac(), reason="Docker socket mounting and container networking do not work reliably on Docker Desktop for macOS"
168+
)
161169
@pytest.mark.skipif(not Path(tcc.ryuk_docker_socket).exists(), reason="No docker socket available")
162170
def test_dood(python_testcontainer_image: str) -> None:
163171
"""

core/tests/test_ryuk.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,14 @@
88
from testcontainers.core.config import testcontainers_config
99
from testcontainers.core.container import Reaper
1010
from testcontainers.core.container import DockerContainer
11+
from testcontainers.core.utils import is_mac
1112
from testcontainers.core.waiting_utils import wait_for_logs
1213

1314

15+
@pytest.mark.skipif(
16+
is_mac(),
17+
reason="Ryuk container reaping is unreliable on Docker Desktop for macOS due to VM-based container lifecycle handling",
18+
)
1419
@pytest.mark.inside_docker_check
1520
def test_wait_for_reaper(monkeypatch: MonkeyPatch):
1621
Reaper.delete_instance()
@@ -41,6 +46,9 @@ def test_wait_for_reaper(monkeypatch: MonkeyPatch):
4146
Reaper.delete_instance()
4247

4348

49+
@pytest.mark.skipif(
50+
is_mac(), reason="Ryuk disabling behavior is unreliable on Docker Desktop for macOS due to Docker socket emulation"
51+
)
4452
@pytest.mark.inside_docker_check
4553
def test_container_without_ryuk(monkeypatch: MonkeyPatch):
4654
Reaper.delete_instance()

core/tests/test_utils.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ def test_is_windows(monkeypatch: MonkeyPatch) -> None:
3333

3434

3535
def test_is_arm(monkeypatch: MonkeyPatch) -> None:
36+
monkeypatch.setattr("platform.machine", lambda: "x86_64")
3637
assert not utils.is_arm()
3738
monkeypatch.setattr("platform.machine", lambda: "arm64")
3839
assert utils.is_arm()

modules/chroma/testcontainers/chroma/__init__.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,13 @@ class ChromaContainer(DockerContainer):
3232

3333
def __init__(
3434
self,
35-
image: str = "chromadb/chroma:latest",
35+
image: str = "chromadb/chroma:1.0.0",
3636
port: int = 8000,
3737
**kwargs,
3838
) -> None:
3939
"""
4040
Args:
41-
image: Docker image to use for the MinIO container.
41+
image: Docker image to use for the ChromaDB container.
4242
port: Port to expose on the container.
4343
access_key: Access key for client connections.
4444
secret_key: Secret key for client connections.
@@ -55,7 +55,7 @@ def get_config(self) -> dict:
5555
including the endpoint.
5656
5757
Returns:
58-
dict: {`endpoint`: str}
58+
dict: {`endpoint`: str, `host`: str, `port`: int}
5959
"""
6060
host_ip = self.get_container_host_ip()
6161
exposed_port = self.get_exposed_port(self.port)
@@ -69,7 +69,7 @@ def get_config(self) -> dict:
6969
def _healthcheck(self) -> None:
7070
"""This is an internal method used to check if the Chroma container
7171
is healthy and ready to receive requests."""
72-
url = f"http://{self.get_config()['endpoint']}/api/v1/heartbeat"
72+
url = f"http://{self.get_config()['endpoint']}/api/v2/heartbeat"
7373
response: Response = get(url)
7474
response.raise_for_status()
7575

modules/chroma/tests/test_chroma.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44

55
def test_docker_run_chroma():
6-
with ChromaContainer(image="chromadb/chroma:0.4.24") as chroma:
6+
with ChromaContainer(image="chromadb/chroma:1.0.0") as chroma:
77
client = chromadb.HttpClient(host=chroma.get_config()["host"], port=chroma.get_config()["port"])
88
col = client.get_or_create_collection("test")
99
assert col.name == "test"

modules/cosmosdb/testcontainers/cosmosdb/mongodb.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def __init__(
2727
self,
2828
mongodb_version: str,
2929
image: str = os.getenv(
30-
"AZURE_COSMOS_EMULATOR_IMAGE", "mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:mongodb"
30+
"AZURE_COSMOS_EMULATOR_IMAGE", "mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:latest"
3131
),
3232
**other_kwargs,
3333
):

modules/postgres/tests/test_postgres.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,21 @@ def test_none_driver_urls():
133133

134134
url = container.get_connection_url(driver=None)
135135
assert url == expected_url
136+
137+
138+
def test_psycopg_versions():
139+
"""Test that both psycopg2 and psycopg (v2 and v3) work with the container."""
140+
141+
postgres_container = PostgresContainer("postgres:16-alpine", driver="psycopg2")
142+
with postgres_container as postgres:
143+
engine = sqlalchemy.create_engine(postgres.get_connection_url())
144+
with engine.begin() as connection:
145+
result = connection.execute(sqlalchemy.text("SELECT 1 as test"))
146+
assert result.scalar() == 1
147+
148+
postgres_container = PostgresContainer("postgres:16-alpine", driver="psycopg")
149+
with postgres_container as postgres:
150+
engine = sqlalchemy.create_engine(postgres.get_connection_url())
151+
with engine.begin() as connection:
152+
result = connection.execute(sqlalchemy.text("SELECT 1 as test"))
153+
assert result.scalar() == 1

modules/qdrant/testcontainers/qdrant/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ class QdrantContainer(DbContainer):
3939

4040
def __init__(
4141
self,
42-
image: str = "qdrant/qdrant:v1.8.3",
42+
image: str = "qdrant/qdrant:v1.13.5",
4343
rest_port: int = 6333,
4444
grpc_port: int = 6334,
4545
api_key: Optional[str] = None,

modules/rabbitmq/testcontainers/rabbitmq/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
class RabbitMqContainer(DockerContainer):
1111
"""
1212
Test container for RabbitMQ. The example below spins up a RabbitMQ broker and uses the
13-
`pika client library <(https://pypi.org/project/pika/)>`__ to establish a connection to the
13+
`pika client library <https://pypi.org/project/pika/>`__ to establish a connection to the
1414
broker.
1515
1616
Example:

0 commit comments

Comments
 (0)