Skip to content

Commit a10a934

Browse files
committed
feat(core): Protocol support for container port bind and expose
1 parent 925329d commit a10a934

File tree

5 files changed

+157
-3
lines changed

5 files changed

+157
-3
lines changed

conf.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,4 +161,9 @@
161161
intersphinx_mapping = {
162162
"python": ("https://docs.python.org/3", None),
163163
"selenium": ("https://seleniumhq.github.io/selenium/docs/api/py/", None),
164+
"typing_extensions": ("https://typing-extensions.readthedocs.io/en/latest/", None),
164165
}
166+
167+
nitpick_ignore = [
168+
("py:class", "typing_extensions.Self"),
169+
]

core/README.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@ Testcontainers Core
44
:code:`testcontainers-core` is the core functionality for spinning up Docker containers in test environments.
55

66
.. autoclass:: testcontainers.core.container.DockerContainer
7+
:members: with_bind_ports, with_exposed_ports
8+
9+
.. note::
10+
When using `with_bind_ports` or `with_exposed_ports`
11+
you can specify the port in the following formats: :code:`{private_port}/{protocol}`
12+
13+
e.g. `8080/tcp` or `8125/udp` or just `8080` (default protocol is tcp)
14+
15+
For legacy reasons, the port can be an *integer*
716

817
.. autoclass:: testcontainers.core.image.DockerImage
918

core/testcontainers/core/container.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import contextlib
22
from platform import system
33
from socket import socket
4-
from typing import TYPE_CHECKING, Optional
4+
from typing import TYPE_CHECKING, Optional, Union
55

66
import docker.errors
77
from docker import version
@@ -57,11 +57,38 @@ def with_env(self, key: str, value: str) -> Self:
5757
self.env[key] = value
5858
return self
5959

60-
def with_bind_ports(self, container: int, host: Optional[int] = None) -> Self:
60+
def with_bind_ports(self, container: Union[str, int], host: Optional[Union[str, int]] = None) -> Self:
61+
"""
62+
Bind container port to host port
63+
64+
:param container: container port
65+
:param host: host port
66+
67+
:doctest:
68+
69+
>>> from testcontainers.core.container import DockerContainer
70+
>>> container = DockerContainer("alpine:latest")
71+
>>> container.with_bind_ports("8080/tcp", 8080)
72+
73+
"""
74+
6175
self.ports[container] = host
6276
return self
6377

64-
def with_exposed_ports(self, *ports: int) -> Self:
78+
def with_exposed_ports(self, *ports: tuple[Union[str, int], ...]) -> Self:
79+
"""
80+
Expose ports from the container without binding them to the host.
81+
82+
:param ports: ports to expose
83+
84+
:doctest:
85+
86+
>>> from testcontainers.core.container import DockerContainer
87+
>>> container = DockerContainer("alpine:latest")
88+
>>> container.with_exposed_ports(8080/tcp, 8081)
89+
90+
"""
91+
6592
for port in ports:
6693
self.ports[port] = None
6794
return self

core/tests/conftest.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import pytest
22
from typing import Callable
33
from testcontainers.core.container import DockerClient
4+
from pprint import pprint
45

56

67
@pytest.fixture
@@ -20,3 +21,16 @@ def _check_for_image(image_short_id: str, cleaned: bool) -> None:
2021
assert found is not cleaned, f'Image {image_short_id} was {"found" if cleaned else "not found"}'
2122

2223
return _check_for_image
24+
25+
26+
@pytest.fixture
27+
def show_container_attributes() -> None:
28+
"""Wrap the show_container_attributes function in a fixture"""
29+
30+
def _show_container_attributes(container_id: str) -> None:
31+
"""Print the attributes of a container"""
32+
client = DockerClient().client
33+
data = client.containers.get(container_id).attrs
34+
pprint(data)
35+
36+
return _show_container_attributes

core/tests/test_core_ports.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import pytest
2+
from typing import Union, Optional
3+
from testcontainers.core.container import DockerContainer
4+
5+
from docker.errors import APIError
6+
7+
8+
@pytest.mark.parametrize(
9+
"container_port, host_port",
10+
[
11+
("8080", "8080"),
12+
("8125/udp", "8125/udp"),
13+
("8092/udp", "8092/udp"),
14+
("9000/tcp", "9000/tcp"),
15+
("8080", "8080/udp"),
16+
(8080, 8080),
17+
(9000, None),
18+
("9009", None),
19+
("9000", ""),
20+
("9000/udp", ""),
21+
],
22+
)
23+
def test_docker_container_with_bind_ports(container_port: Union[str, int], host_port: Optional[Union[str, int]]):
24+
container = DockerContainer("alpine:latest")
25+
container.with_bind_ports(container_port, host_port)
26+
container.start()
27+
28+
container_id = container._container.id
29+
client = container._container.client
30+
31+
if isinstance(container_port, int):
32+
container_port = str(container_port)
33+
if isinstance(host_port, int):
34+
host_port = str(host_port)
35+
if not host_port:
36+
host_port = ""
37+
38+
# if the port protocol is not specified, it will default to tcp
39+
if "/" not in container_port:
40+
container_port += "/tcp"
41+
42+
excepted = {container_port: [{"HostIp": "", "HostPort": host_port}]}
43+
assert client.containers.get(container_id).attrs["HostConfig"]["PortBindings"] == excepted
44+
container.stop()
45+
46+
47+
@pytest.mark.parametrize(
48+
"container_port, host_port",
49+
[
50+
("0", "8080"),
51+
("8080", "abc"),
52+
(0, 0),
53+
(-1, 8080),
54+
(None, 8080),
55+
],
56+
)
57+
def test_error_docker_container_with_bind_ports(container_port: Union[str, int], host_port: Optional[Union[str, int]]):
58+
with pytest.raises(APIError):
59+
container = DockerContainer("alpine:latest")
60+
container.with_bind_ports(container_port, host_port)
61+
container.start()
62+
63+
64+
@pytest.mark.parametrize(
65+
"ports, expected",
66+
[
67+
(("8125/udp",), {"8125/udp": {}}),
68+
(("8092/udp", "9000/tcp"), {"8092/udp": {}, "9000/tcp": {}}),
69+
(("8080", "8080/udp"), {"8080/tcp": {}, "8080/udp": {}}),
70+
((9000,), {"9000/tcp": {}}),
71+
((8080, 8080), {"8080/tcp": {}}),
72+
(("9001", 9002), {"9001/tcp": {}, "9002/tcp": {}}),
73+
(("9001", 9002, "9003/udp", 9004), {"9001/tcp": {}, "9002/tcp": {}, "9003/udp": {}, "9004/tcp": {}}),
74+
],
75+
)
76+
def test_docker_container_with_exposed_ports(ports: tuple[Union[str, int], ...], expected: dict):
77+
container = DockerContainer("alpine:latest")
78+
container.with_exposed_ports(*ports)
79+
container.start()
80+
81+
container_id = container._container.id
82+
client = container._container.client
83+
assert client.containers.get(container_id).attrs["Config"]["ExposedPorts"] == expected
84+
container.stop()
85+
86+
87+
@pytest.mark.parametrize(
88+
"ports",
89+
[
90+
((9000, None)),
91+
(("", 9000)),
92+
("tcp", ""),
93+
],
94+
)
95+
def test_error_docker_container_with_exposed_ports(ports: tuple[Union[str, int], ...]):
96+
with pytest.raises(APIError):
97+
container = DockerContainer("alpine:latest")
98+
container.with_exposed_ports(*ports)
99+
container.start()

0 commit comments

Comments
 (0)