Skip to content

Commit a0d4317

Browse files
feat(core): Protocol support for container port bind and expose (#690)
Fix: #674 Changes: 1. Proper type hinting for ports bindings, support strings like `8080/tcp` or `8125/udp` 2. Backward compatible with `int` 3. More test coverage 4. Improve documentations regarding the usage of `with_bind_ports` and `with_exposed_ports` Any comments will be appreciated --------- Co-authored-by: David Ankin <[email protected]>
1 parent e9e40f9 commit a0d4317

File tree

5 files changed

+156
-2
lines changed

5 files changed

+156
-2
lines changed

conf.py

+5
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

+9
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

+29-2
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,38 @@ def with_env_file(self, env_file: Union[str, PathLike]) -> Self:
6565
self.with_env(key, value)
6666
return self
6767

68-
def with_bind_ports(self, container: int, host: Optional[int] = None) -> Self:
68+
def with_bind_ports(self, container: Union[str, int], host: Optional[Union[str, int]] = None) -> Self:
69+
"""
70+
Bind container port to host port
71+
72+
:param container: container port
73+
:param host: host port
74+
75+
:doctest:
76+
77+
>>> from testcontainers.core.container import DockerContainer
78+
>>> container = DockerContainer("nginx")
79+
>>> container = container.with_bind_ports("8080/tcp", 8080)
80+
>>> container = container.with_bind_ports("8081/tcp", 8081)
81+
82+
"""
6983
self.ports[container] = host
7084
return self
7185

72-
def with_exposed_ports(self, *ports: int) -> Self:
86+
def with_exposed_ports(self, *ports: Union[str, int]) -> Self:
87+
"""
88+
Expose ports from the container without binding them to the host.
89+
90+
:param ports: ports to expose
91+
92+
:doctest:
93+
94+
>>> from testcontainers.core.container import DockerContainer
95+
>>> container = DockerContainer("nginx")
96+
>>> container = container.with_exposed_ports("8080/tcp", "8081/tcp")
97+
98+
"""
99+
73100
for port in ports:
74101
self.ports[port] = None
75102
return self

core/tests/conftest.py

+14
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import pytest
44
from typing import Callable
55
from testcontainers.core.container import DockerClient
6+
from pprint import pprint
67
import sys
78

89
PROJECT_DIR = Path(__file__).parent.parent.parent.resolve()
@@ -50,3 +51,16 @@ def _check_for_image(image_short_id: str, cleaned: bool) -> None:
5051
assert found is not cleaned, f'Image {image_short_id} was {"found" if cleaned else "not found"}'
5152

5253
return _check_for_image
54+
55+
56+
@pytest.fixture
57+
def show_container_attributes() -> None:
58+
"""Wrap the show_container_attributes function in a fixture"""
59+
60+
def _show_container_attributes(container_id: str) -> None:
61+
"""Print the attributes of a container"""
62+
client = DockerClient().client
63+
data = client.containers.get(container_id).attrs
64+
pprint(data)
65+
66+
return _show_container_attributes

core/tests/test_core_ports.py

+99
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+
# prepare to inspect container
29+
container_id = container._container.id
30+
client = container._container.client
31+
32+
# assemble expected output to compare to container API
33+
container_port = str(container_port)
34+
host_port = str(host_port or "")
35+
36+
# if the port protocol is not specified, it will default to tcp
37+
if "/" not in container_port:
38+
container_port += "/tcp"
39+
40+
expected = {container_port: [{"HostIp": "", "HostPort": host_port}]}
41+
42+
# compare PortBindings to expected output
43+
assert client.containers.get(container_id).attrs["HostConfig"]["PortBindings"] == expected
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)