From a10a9346455d555c2aaffa81be8a742a090b376a Mon Sep 17 00:00:00 2001 From: Roy Moore Date: Wed, 21 Aug 2024 21:28:56 +0000 Subject: [PATCH 1/7] feat(core): Protocol support for container port bind and expose --- conf.py | 5 ++ core/README.rst | 9 +++ core/testcontainers/core/container.py | 33 ++++++++- core/tests/conftest.py | 14 ++++ core/tests/test_core_ports.py | 99 +++++++++++++++++++++++++++ 5 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 core/tests/test_core_ports.py diff --git a/conf.py b/conf.py index b310e939..35c2ae9c 100644 --- a/conf.py +++ b/conf.py @@ -161,4 +161,9 @@ intersphinx_mapping = { "python": ("https://docs.python.org/3", None), "selenium": ("https://seleniumhq.github.io/selenium/docs/api/py/", None), + "typing_extensions": ("https://typing-extensions.readthedocs.io/en/latest/", None), } + +nitpick_ignore = [ + ("py:class", "typing_extensions.Self"), +] diff --git a/core/README.rst b/core/README.rst index 8cc9a278..1461ba7d 100644 --- a/core/README.rst +++ b/core/README.rst @@ -4,6 +4,15 @@ Testcontainers Core :code:`testcontainers-core` is the core functionality for spinning up Docker containers in test environments. .. autoclass:: testcontainers.core.container.DockerContainer + :members: with_bind_ports, with_exposed_ports + +.. note:: + When using `with_bind_ports` or `with_exposed_ports` + you can specify the port in the following formats: :code:`{private_port}/{protocol}` + + e.g. `8080/tcp` or `8125/udp` or just `8080` (default protocol is tcp) + + For legacy reasons, the port can be an *integer* .. autoclass:: testcontainers.core.image.DockerImage diff --git a/core/testcontainers/core/container.py b/core/testcontainers/core/container.py index e9415441..14d8e12f 100644 --- a/core/testcontainers/core/container.py +++ b/core/testcontainers/core/container.py @@ -1,7 +1,7 @@ import contextlib from platform import system from socket import socket -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Optional, Union import docker.errors from docker import version @@ -57,11 +57,38 @@ def with_env(self, key: str, value: str) -> Self: self.env[key] = value return self - def with_bind_ports(self, container: int, host: Optional[int] = None) -> Self: + def with_bind_ports(self, container: Union[str, int], host: Optional[Union[str, int]] = None) -> Self: + """ + Bind container port to host port + + :param container: container port + :param host: host port + + :doctest: + + >>> from testcontainers.core.container import DockerContainer + >>> container = DockerContainer("alpine:latest") + >>> container.with_bind_ports("8080/tcp", 8080) + + """ + self.ports[container] = host return self - def with_exposed_ports(self, *ports: int) -> Self: + def with_exposed_ports(self, *ports: tuple[Union[str, int], ...]) -> Self: + """ + Expose ports from the container without binding them to the host. + + :param ports: ports to expose + + :doctest: + + >>> from testcontainers.core.container import DockerContainer + >>> container = DockerContainer("alpine:latest") + >>> container.with_exposed_ports(8080/tcp, 8081) + + """ + for port in ports: self.ports[port] = None return self diff --git a/core/tests/conftest.py b/core/tests/conftest.py index 4f69565f..e2dd5a1c 100644 --- a/core/tests/conftest.py +++ b/core/tests/conftest.py @@ -1,6 +1,7 @@ import pytest from typing import Callable from testcontainers.core.container import DockerClient +from pprint import pprint @pytest.fixture @@ -20,3 +21,16 @@ def _check_for_image(image_short_id: str, cleaned: bool) -> None: assert found is not cleaned, f'Image {image_short_id} was {"found" if cleaned else "not found"}' return _check_for_image + + +@pytest.fixture +def show_container_attributes() -> None: + """Wrap the show_container_attributes function in a fixture""" + + def _show_container_attributes(container_id: str) -> None: + """Print the attributes of a container""" + client = DockerClient().client + data = client.containers.get(container_id).attrs + pprint(data) + + return _show_container_attributes diff --git a/core/tests/test_core_ports.py b/core/tests/test_core_ports.py new file mode 100644 index 00000000..25541e3d --- /dev/null +++ b/core/tests/test_core_ports.py @@ -0,0 +1,99 @@ +import pytest +from typing import Union, Optional +from testcontainers.core.container import DockerContainer + +from docker.errors import APIError + + +@pytest.mark.parametrize( + "container_port, host_port", + [ + ("8080", "8080"), + ("8125/udp", "8125/udp"), + ("8092/udp", "8092/udp"), + ("9000/tcp", "9000/tcp"), + ("8080", "8080/udp"), + (8080, 8080), + (9000, None), + ("9009", None), + ("9000", ""), + ("9000/udp", ""), + ], +) +def test_docker_container_with_bind_ports(container_port: Union[str, int], host_port: Optional[Union[str, int]]): + container = DockerContainer("alpine:latest") + container.with_bind_ports(container_port, host_port) + container.start() + + container_id = container._container.id + client = container._container.client + + if isinstance(container_port, int): + container_port = str(container_port) + if isinstance(host_port, int): + host_port = str(host_port) + if not host_port: + host_port = "" + + # if the port protocol is not specified, it will default to tcp + if "/" not in container_port: + container_port += "/tcp" + + excepted = {container_port: [{"HostIp": "", "HostPort": host_port}]} + assert client.containers.get(container_id).attrs["HostConfig"]["PortBindings"] == excepted + container.stop() + + +@pytest.mark.parametrize( + "container_port, host_port", + [ + ("0", "8080"), + ("8080", "abc"), + (0, 0), + (-1, 8080), + (None, 8080), + ], +) +def test_error_docker_container_with_bind_ports(container_port: Union[str, int], host_port: Optional[Union[str, int]]): + with pytest.raises(APIError): + container = DockerContainer("alpine:latest") + container.with_bind_ports(container_port, host_port) + container.start() + + +@pytest.mark.parametrize( + "ports, expected", + [ + (("8125/udp",), {"8125/udp": {}}), + (("8092/udp", "9000/tcp"), {"8092/udp": {}, "9000/tcp": {}}), + (("8080", "8080/udp"), {"8080/tcp": {}, "8080/udp": {}}), + ((9000,), {"9000/tcp": {}}), + ((8080, 8080), {"8080/tcp": {}}), + (("9001", 9002), {"9001/tcp": {}, "9002/tcp": {}}), + (("9001", 9002, "9003/udp", 9004), {"9001/tcp": {}, "9002/tcp": {}, "9003/udp": {}, "9004/tcp": {}}), + ], +) +def test_docker_container_with_exposed_ports(ports: tuple[Union[str, int], ...], expected: dict): + container = DockerContainer("alpine:latest") + container.with_exposed_ports(*ports) + container.start() + + container_id = container._container.id + client = container._container.client + assert client.containers.get(container_id).attrs["Config"]["ExposedPorts"] == expected + container.stop() + + +@pytest.mark.parametrize( + "ports", + [ + ((9000, None)), + (("", 9000)), + ("tcp", ""), + ], +) +def test_error_docker_container_with_exposed_ports(ports: tuple[Union[str, int], ...]): + with pytest.raises(APIError): + container = DockerContainer("alpine:latest") + container.with_exposed_ports(*ports) + container.start() From 0f36b373700cee895a9f5f420b03676c6365b7a2 Mon Sep 17 00:00:00 2001 From: Roy Moore Date: Wed, 21 Aug 2024 21:38:14 +0000 Subject: [PATCH 2/7] fix doctest --- core/testcontainers/core/container.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/testcontainers/core/container.py b/core/testcontainers/core/container.py index 62fb0b91..84fc8d27 100644 --- a/core/testcontainers/core/container.py +++ b/core/testcontainers/core/container.py @@ -69,6 +69,7 @@ def with_bind_ports(self, container: Union[str, int], host: Optional[Union[str, >>> from testcontainers.core.container import DockerContainer >>> container = DockerContainer("alpine:latest") >>> container.with_bind_ports("8080/tcp", 8080) + >>> container.with_bind_ports("8081/tcp", 8081) """ @@ -85,7 +86,7 @@ def with_exposed_ports(self, *ports: tuple[Union[str, int], ...]) -> Self: >>> from testcontainers.core.container import DockerContainer >>> container = DockerContainer("alpine:latest") - >>> container.with_exposed_ports(8080/tcp, 8081) + >>> container.with_exposed_ports("8080/tcp", "8081/tcp") """ From f28b07dbdbdf3ec244aded5108d861b00ebb6563 Mon Sep 17 00:00:00 2001 From: Roy Moore Date: Thu, 22 Aug 2024 04:34:36 +0000 Subject: [PATCH 3/7] fix doctest --- core/testcontainers/core/container.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/core/testcontainers/core/container.py b/core/testcontainers/core/container.py index 84fc8d27..b55910d5 100644 --- a/core/testcontainers/core/container.py +++ b/core/testcontainers/core/container.py @@ -67,9 +67,9 @@ def with_bind_ports(self, container: Union[str, int], host: Optional[Union[str, :doctest: >>> from testcontainers.core.container import DockerContainer - >>> container = DockerContainer("alpine:latest") - >>> container.with_bind_ports("8080/tcp", 8080) - >>> container.with_bind_ports("8081/tcp", 8081) + >>> container = DockerContainer("nginx") + >>> container = container.with_bind_ports("8080/tcp", 8080) + >>> container = container.with_bind_ports("8081/tcp", 8081) """ @@ -85,8 +85,8 @@ def with_exposed_ports(self, *ports: tuple[Union[str, int], ...]) -> Self: :doctest: >>> from testcontainers.core.container import DockerContainer - >>> container = DockerContainer("alpine:latest") - >>> container.with_exposed_ports("8080/tcp", "8081/tcp") + >>> container = DockerContainer("nginx") + >>> container = container.with_exposed_ports("8080/tcp", "8081/tcp") """ From 1a7ac708f31c0417d812a9498bf16772379180ec Mon Sep 17 00:00:00 2001 From: Roy Moore Date: Thu, 22 Aug 2024 05:58:11 +0000 Subject: [PATCH 4/7] CR fix --- core/tests/test_core_ports.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/core/tests/test_core_ports.py b/core/tests/test_core_ports.py index 25541e3d..148ddf08 100644 --- a/core/tests/test_core_ports.py +++ b/core/tests/test_core_ports.py @@ -25,22 +25,22 @@ def test_docker_container_with_bind_ports(container_port: Union[str, int], host_ container.with_bind_ports(container_port, host_port) container.start() + # prepare to inspect container container_id = container._container.id client = container._container.client - if isinstance(container_port, int): - container_port = str(container_port) - if isinstance(host_port, int): - host_port = str(host_port) - if not host_port: - host_port = "" + # assemble expected output to compare to container API + container_port = str(container_port) + host_port = str(host_port or "") # if the port protocol is not specified, it will default to tcp if "/" not in container_port: container_port += "/tcp" - excepted = {container_port: [{"HostIp": "", "HostPort": host_port}]} - assert client.containers.get(container_id).attrs["HostConfig"]["PortBindings"] == excepted + expected = {container_port: [{"HostIp": "", "HostPort": host_port}]} + + # compare PortBindings to expected output + assert client.containers.get(container_id).attrs["HostConfig"]["PortBindings"] == expected container.stop() From fb2645d591b1a4ae768428318b3f7153bbc277be Mon Sep 17 00:00:00 2001 From: Roy Moore Date: Mon, 16 Dec 2024 15:34:58 +0300 Subject: [PATCH 5/7] Update core/testcontainers/core/container.py --- core/testcontainers/core/container.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/testcontainers/core/container.py b/core/testcontainers/core/container.py index fabd9a31..0afdc62e 100644 --- a/core/testcontainers/core/container.py +++ b/core/testcontainers/core/container.py @@ -58,7 +58,7 @@ def __init__( def with_env(self, key: str, value: str) -> Self: self.env[key] = value return self - + def with_env_file(self, env_file: Union[str, PathLike]) -> Self: env_values = dotenv_values(env_file) for key, value in env_values.items(): From 26e30ff77e77211c975565aea8fc4b568f0d3451 Mon Sep 17 00:00:00 2001 From: Roy Moore Date: Mon, 16 Dec 2024 15:35:41 +0300 Subject: [PATCH 6/7] Update core/testcontainers/core/container.py --- core/testcontainers/core/container.py | 1 - 1 file changed, 1 deletion(-) diff --git a/core/testcontainers/core/container.py b/core/testcontainers/core/container.py index 0afdc62e..50856409 100644 --- a/core/testcontainers/core/container.py +++ b/core/testcontainers/core/container.py @@ -58,7 +58,6 @@ def __init__( def with_env(self, key: str, value: str) -> Self: self.env[key] = value return self - def with_env_file(self, env_file: Union[str, PathLike]) -> Self: env_values = dotenv_values(env_file) for key, value in env_values.items(): From dec26d7a3e290fc35c84f24114750cbcc912a0f9 Mon Sep 17 00:00:00 2001 From: Roy Moore Date: Mon, 16 Dec 2024 15:36:12 +0300 Subject: [PATCH 7/7] Update core/testcontainers/core/container.py --- core/testcontainers/core/container.py | 1 + 1 file changed, 1 insertion(+) diff --git a/core/testcontainers/core/container.py b/core/testcontainers/core/container.py index 50856409..da486fb5 100644 --- a/core/testcontainers/core/container.py +++ b/core/testcontainers/core/container.py @@ -58,6 +58,7 @@ def __init__( def with_env(self, key: str, value: str) -> Self: self.env[key] = value return self + def with_env_file(self, env_file: Union[str, PathLike]) -> Self: env_values = dotenv_values(env_file) for key, value in env_values.items():