From b577d3f3e3a357dbdfa2950daf0eeb737401d222 Mon Sep 17 00:00:00 2001 From: Roy Moore Date: Sat, 27 Apr 2024 17:41:25 +0000 Subject: [PATCH 1/4] feat(core): Image build support (using Dockerfile) Added DockerImage to core Make sure all build related stuff are removed after Fix Image related issues Improve image removal code Add test from docker client kwargs image Added core test from image flow Improve docstring for Image Update core Readme to include image and full example Update core Readme to include image and full example Fix usage of image name Update test from image string Replace image name with tag Update image remove handling Fix image usage doctest --- core/README.rst | 13 +++++ core/testcontainers/core/docker_client.py | 21 +++++++ core/testcontainers/core/image.py | 70 +++++++++++++++++++++++ core/tests/test_core.py | 23 ++++++++ core/tests/test_docker_client.py | 10 ++++ 5 files changed, 137 insertions(+) create mode 100644 core/testcontainers/core/image.py diff --git a/core/README.rst b/core/README.rst index 2256bd204..a57ab33f8 100644 --- a/core/README.rst +++ b/core/README.rst @@ -4,3 +4,16 @@ testcontainers-core :code:`testcontainers-core` is the core functionality for spinning up Docker containers in test environments. .. autoclass:: testcontainers.core.container.DockerContainer + +.. autoclass:: testcontainers.core.image.DockerImage + +Conjoint usage: + +.. doctest:: + >>> from testcontainers.core.container import DockerContainer + >>> from testcontainers.core.waiting_utils import wait_for_logs + >>> from testcontainers.core.image import DockerImage + + >>> with DockerImage(tag="test-image:latest", path=".") as image: + ... with DockerContainer(tag=image.tag) as container: + ... delay = wait_for_log(container, "Server ready", 60) diff --git a/core/testcontainers/core/docker_client.py b/core/testcontainers/core/docker_client.py index e43dddb41..5acf58ad3 100644 --- a/core/testcontainers/core/docker_client.py +++ b/core/testcontainers/core/docker_client.py @@ -16,10 +16,12 @@ import os import urllib import urllib.parse +from collections.abc import Iterable from typing import Callable, Optional, TypeVar, Union import docker from docker.models.containers import Container, ContainerCollection +from docker.models.images import Image, ImageCollection from typing_extensions import ParamSpec from testcontainers.core.config import testcontainers_config as c @@ -40,6 +42,14 @@ def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T: return wrapper +def _wrapped_image_collection(function: Callable[_P, _T]) -> Callable[_P, _T]: + @ft.wraps(ImageCollection.build) + def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T: + return function(*args, **kwargs) + + return wrapper + + class DockerClient: """ Thin wrapper around :class:`docker.DockerClient` for a more functional interface. @@ -91,6 +101,17 @@ def run( ) return container + @_wrapped_image_collection + def build(self, path: str, tag: str, rm: bool = True, **kwargs) -> tuple[Image, Iterable[dict]]: + """ + Build a Docker image from a directory containing the Dockerfile. + + :return: A tuple containing the image object and the build logs. + """ + image_object, image_logs = self.client.images.build(path=path, tag=tag, rm=rm, **kwargs) + + return image_object, image_logs + def find_host_network(self) -> Optional[str]: """ Try to find the docker host network. diff --git a/core/testcontainers/core/image.py b/core/testcontainers/core/image.py new file mode 100644 index 000000000..434749276 --- /dev/null +++ b/core/testcontainers/core/image.py @@ -0,0 +1,70 @@ +from typing import TYPE_CHECKING, Optional + +from typing_extensions import Self + +from testcontainers.core.docker_client import DockerClient +from testcontainers.core.utils import setup_logger + +if TYPE_CHECKING: + from docker.models.containers import Image + +logger = setup_logger(__name__) + + +class DockerImage: + """ + Basic image object to build Docker images. + + .. doctest:: + >>> from testcontainers.core.image import DockerImage + >>> with DockerImage(tag="new_image", path=".") as image: + ... logs = image.get_logs() + + :param tag: Tag of the image + :param path: Path to the Dockerfile + """ + + def __init__( + self, + tag: str, + path: str, + docker_client_kw: Optional[dict] = None, + **kwargs, + ) -> None: + self.tag = tag + self.path = path + self._docker = DockerClient(**(docker_client_kw or {})) + self._kwargs = kwargs + + def build(self, **kwargs) -> Self: + logger.info(f"Building image {self.tag} from {self.path}") + docker_client = self.get_docker_client() + self._image, self._logs = docker_client.build(path=self.path, tag=self.tag, **kwargs) + logger.info(f"Built image {self.tag}") + return self + + def remove(self, force=True, noprune=False) -> None: + """ + Remove the image. + + :param force: Remove the image even if it is in use + :param noprune: Do not delete untagged parent images + """ + if self._image: + self._image.remove(force=force, noprune=noprune) + self.get_docker_client().client.close() + + def __enter__(self) -> Self: + return self.build() + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self.remove() + + def get_wrapped_image(self) -> "Image": + return self._image + + def get_docker_client(self) -> DockerClient: + return self._docker + + def get_logs(self) -> list[dict]: + return list(self._logs) diff --git a/core/tests/test_core.py b/core/tests/test_core.py index 4ebe90409..994ee4803 100644 --- a/core/tests/test_core.py +++ b/core/tests/test_core.py @@ -1,6 +1,8 @@ import pytest +import tempfile from testcontainers.core.container import DockerContainer +from testcontainers.core.image import DockerImage from testcontainers.core.waiting_utils import wait_for_logs @@ -31,3 +33,24 @@ def test_can_get_logs(): assert isinstance(stdout, bytes) assert isinstance(stderr, bytes) assert stdout, "There should be something on stdout" + + +def test_from_image(): + test_image_tag = "test-image:latest" + with tempfile.TemporaryDirectory() as temp_directory: + with open(f"{temp_directory}/Dockerfile", "w") as f: + f.write( + """ + FROM alpine:latest + CMD echo "Hello from Docker Image!" + """ + ) + f.close() + with DockerImage(tag=test_image_tag, path=temp_directory) as image: + assert image.tag == test_image_tag + logs = image.get_logs() + assert isinstance(logs, list) + assert logs[0] == {"stream": "Step 1/2 : FROM alpine:latest"} + assert logs[3] == {"stream": 'Step 2/2 : CMD echo "Hello from Docker Image!"'} + with DockerContainer(image.tag) as container: + wait_for_logs(container, "Hello from Docker Image!") diff --git a/core/tests/test_docker_client.py b/core/tests/test_docker_client.py index 23f92e9e5..6d21bf384 100644 --- a/core/tests/test_docker_client.py +++ b/core/tests/test_docker_client.py @@ -4,6 +4,7 @@ from testcontainers.core.container import DockerContainer from testcontainers.core.docker_client import DockerClient +from testcontainers.core.image import DockerImage def test_docker_client_from_env(): @@ -22,3 +23,12 @@ def test_container_docker_client_kw(): DockerContainer(image="", docker_client_kw=test_kwargs) mock_docker.from_env.assert_called_with(**test_kwargs) + + +def test_image_docker_client_kw(): + test_kwargs = {"test_kw": "test_value"} + mock_docker = MagicMock(spec=docker) + with patch("testcontainers.core.docker_client.docker", mock_docker): + DockerImage(name="", path="", docker_client_kw=test_kwargs) + + mock_docker.from_env.assert_called_with(**test_kwargs) From 6f140f182725d05036aad521a4bbd728f6174497 Mon Sep 17 00:00:00 2001 From: Roy Moore Date: Mon, 6 May 2024 16:35:48 +0000 Subject: [PATCH 2/4] feat(core): Added SrvContainer Added doctest to CustomContainer and update readme Fix issue with CustomContainer image handle Add test for CustomContainer Update image var on CustomContainer Image build based on id Image clean is now optional Fixed doctest Update log about image removal Improve image related test flow Refactor CustomContainer into SrvContainer Refactor image related test flow Improve SrvContainer Fix test_srv_container logs check Improve SrvContainer and update tests Updates for SrvContainer --- core/README.rst | 11 +-- core/testcontainers/core/generic.py | 67 +++++++++++++++++++ core/testcontainers/core/image.py | 31 +++++++-- core/tests/conftest.py | 22 ++++++ .../{ => image_fixtures/busybox}/Dockerfile | 0 .../image_fixtures/python_server/Dockerfile | 3 + core/tests/image_fixtures/sample/Dockerfile | 2 + core/tests/test_core.py | 36 ++++++---- core/tests/test_generics.py | 27 ++++++++ 9 files changed, 175 insertions(+), 24 deletions(-) create mode 100644 core/tests/conftest.py rename core/tests/{ => image_fixtures/busybox}/Dockerfile (100%) create mode 100644 core/tests/image_fixtures/python_server/Dockerfile create mode 100644 core/tests/image_fixtures/sample/Dockerfile create mode 100644 core/tests/test_generics.py diff --git a/core/README.rst b/core/README.rst index a57ab33f8..b8583999d 100644 --- a/core/README.rst +++ b/core/README.rst @@ -7,13 +7,16 @@ testcontainers-core .. autoclass:: testcontainers.core.image.DockerImage -Conjoint usage: +.. autoclass:: testcontainers.core.generic.SrvContainer + +Using `DockerContainer` and `DockerImage` directly: .. doctest:: + >>> from testcontainers.core.container import DockerContainer >>> from testcontainers.core.waiting_utils import wait_for_logs >>> from testcontainers.core.image import DockerImage - >>> with DockerImage(tag="test-image:latest", path=".") as image: - ... with DockerContainer(tag=image.tag) as container: - ... delay = wait_for_log(container, "Server ready", 60) + >>> with DockerImage(path="./core/tests/image_fixtures/sample/", tag="test-sample:latest") as image: + ... with DockerContainer(str(image)) as container: + ... delay = wait_for_logs(container, "Test Sample Image") diff --git a/core/testcontainers/core/generic.py b/core/testcontainers/core/generic.py index 6dd635e69..d573ab1d3 100644 --- a/core/testcontainers/core/generic.py +++ b/core/testcontainers/core/generic.py @@ -11,10 +11,13 @@ # License for the specific language governing permissions and limitations # under the License. from typing import Optional +from urllib.error import HTTPError from urllib.parse import quote +from urllib.request import urlopen from testcontainers.core.container import DockerContainer from testcontainers.core.exceptions import ContainerStartException +from testcontainers.core.image import DockerImage from testcontainers.core.utils import raise_for_deprecated_parameter from testcontainers.core.waiting_utils import wait_container_is_ready @@ -79,3 +82,67 @@ def _configure(self) -> None: def _transfer_seed(self) -> None: pass + + +class SrvContainer(DockerContainer): + """ + Container for a generic server that is based on a custom image. + + Example: + + .. doctest:: + + >>> import httpx + >>> from testcontainers.core.generic import SrvContainer + >>> from testcontainers.core.waiting_utils import wait_for_logs + + >>> with SrvContainer(path="./core/tests/image_fixtures/python_server", port=9000, tag="test-srv:latest") as srv: + ... url = srv._create_connection_url() + ... response = httpx.get(f"{url}", timeout=5) + ... assert response.status_code == 200, "Response status code is not 200" + ... delay = wait_for_logs(srv, "GET / HTTP/1.1") + + + :param path: Path to the Dockerfile to build the image + :param tag: Tag for the image to be built (default: None) + """ + + def __init__(self, path: str, port: int, tag: Optional[str], image_cleanup: bool = True) -> None: + self.docker_image = DockerImage(path=path, tag=tag, clean_up=image_cleanup).build() + super().__init__(str(self.docker_image)) + self.internal_port = port + self.with_exposed_ports(self.internal_port) + + @wait_container_is_ready(HTTPError) + def _connect(self) -> None: + # noinspection HttpUrlsUsage + url = self._create_connection_url() + try: + with urlopen(url) as r: + assert b"" in r.read() + except HTTPError as e: + # 404 is expected, as the server may not have the specific endpoint we are looking for + if e.code == 404: + pass + else: + raise + + def get_api_url(self) -> str: + raise NotImplementedError + + def _create_connection_url(self) -> str: + if self._container is None: + raise ContainerStartException("container has not been started") + host = self.get_container_host_ip() + exposed_port = self.get_exposed_port(self.internal_port) + url = f"http://{host}:{exposed_port}" + return url + + def start(self) -> "SrvContainer": + super().start() + self._connect() + return self + + def stop(self, force=True, delete_volume=True) -> None: + super().stop(force, delete_volume) + self.docker_image.remove() diff --git a/core/testcontainers/core/image.py b/core/testcontainers/core/image.py index 434749276..fe996ca3b 100644 --- a/core/testcontainers/core/image.py +++ b/core/testcontainers/core/image.py @@ -16,33 +16,46 @@ class DockerImage: Basic image object to build Docker images. .. doctest:: + >>> from testcontainers.core.image import DockerImage - >>> with DockerImage(tag="new_image", path=".") as image: + >>> with DockerImage(path="./core/tests/image_fixtures/sample/", tag="test-image") as image: ... logs = image.get_logs() - :param tag: Tag of the image - :param path: Path to the Dockerfile + :param tag: Tag for the image to be built (default: None) + :param path: Path to the Dockerfile to build the image """ def __init__( self, - tag: str, path: str, docker_client_kw: Optional[dict] = None, + tag: Optional[str] = None, + clean_up: bool = True, **kwargs, ) -> None: self.tag = tag self.path = path + self.id = None self._docker = DockerClient(**(docker_client_kw or {})) + self.clean_up = clean_up self._kwargs = kwargs def build(self, **kwargs) -> Self: - logger.info(f"Building image {self.tag} from {self.path}") + logger.info(f"Building image from {self.path}") docker_client = self.get_docker_client() self._image, self._logs = docker_client.build(path=self.path, tag=self.tag, **kwargs) - logger.info(f"Built image {self.tag}") + logger.info(f"Built image {self.short_id} with tag {self.tag}") return self + @property + def short_id(self) -> str: + """ + The ID of the image truncated to 12 characters, without the ``sha256:`` prefix. + """ + if self._image.id.startswith("sha256:"): + return self._image.id.split(":")[1][:12] + return self._image.id[:12] + def remove(self, force=True, noprune=False) -> None: """ Remove the image. @@ -50,10 +63,14 @@ def remove(self, force=True, noprune=False) -> None: :param force: Remove the image even if it is in use :param noprune: Do not delete untagged parent images """ - if self._image: + if self._image and self.clean_up: + logger.info(f"Removing image {self.short_id}") self._image.remove(force=force, noprune=noprune) self.get_docker_client().client.close() + def __str__(self) -> str: + return f"{self.tag if self.tag else self.short_id}" + def __enter__(self) -> Self: return self.build() diff --git a/core/tests/conftest.py b/core/tests/conftest.py new file mode 100644 index 000000000..4f69565f4 --- /dev/null +++ b/core/tests/conftest.py @@ -0,0 +1,22 @@ +import pytest +from typing import Callable +from testcontainers.core.container import DockerClient + + +@pytest.fixture +def check_for_image() -> Callable[[str, bool], None]: + """Warp the check_for_image function in a fixture""" + + def _check_for_image(image_short_id: str, cleaned: bool) -> None: + """ + Validates if the image is present or not. + + :param image_short_id: The short id of the image + :param cleaned: True if the image should not be present, False otherwise + """ + client = DockerClient() + images = client.client.images.list() + found = any(image.short_id.endswith(image_short_id) for image in images) + assert found is not cleaned, f'Image {image_short_id} was {"found" if cleaned else "not found"}' + + return _check_for_image diff --git a/core/tests/Dockerfile b/core/tests/image_fixtures/busybox/Dockerfile similarity index 100% rename from core/tests/Dockerfile rename to core/tests/image_fixtures/busybox/Dockerfile diff --git a/core/tests/image_fixtures/python_server/Dockerfile b/core/tests/image_fixtures/python_server/Dockerfile new file mode 100644 index 000000000..f09b26ac1 --- /dev/null +++ b/core/tests/image_fixtures/python_server/Dockerfile @@ -0,0 +1,3 @@ +FROM python:3 +EXPOSE 9000 +CMD ["python", "-m", "http.server", "9000"] diff --git a/core/tests/image_fixtures/sample/Dockerfile b/core/tests/image_fixtures/sample/Dockerfile new file mode 100644 index 000000000..d7d786035 --- /dev/null +++ b/core/tests/image_fixtures/sample/Dockerfile @@ -0,0 +1,2 @@ +FROM alpine:latest +CMD echo "Test Sample Image" diff --git a/core/tests/test_core.py b/core/tests/test_core.py index 994ee4803..efac8262e 100644 --- a/core/tests/test_core.py +++ b/core/tests/test_core.py @@ -1,5 +1,8 @@ import pytest import tempfile +import random + +from typing import Optional from testcontainers.core.container import DockerContainer from testcontainers.core.image import DockerImage @@ -35,22 +38,29 @@ def test_can_get_logs(): assert stdout, "There should be something on stdout" -def test_from_image(): - test_image_tag = "test-image:latest" +@pytest.mark.parametrize("test_cleanup", [True, False]) +@pytest.mark.parametrize("test_image_tag", [None, "test-image:latest"]) +def test_docker_image(test_image_tag: Optional[str], test_cleanup: bool, check_for_image): with tempfile.TemporaryDirectory() as temp_directory: + # It's important to use a random string to avoid image caching + random_string = "Hello from Docker Image! " + str(random.randint(0, 1000)) with open(f"{temp_directory}/Dockerfile", "w") as f: f.write( - """ + f""" FROM alpine:latest - CMD echo "Hello from Docker Image!" + CMD echo "{random_string}" """ ) - f.close() - with DockerImage(tag=test_image_tag, path=temp_directory) as image: - assert image.tag == test_image_tag - logs = image.get_logs() - assert isinstance(logs, list) - assert logs[0] == {"stream": "Step 1/2 : FROM alpine:latest"} - assert logs[3] == {"stream": 'Step 2/2 : CMD echo "Hello from Docker Image!"'} - with DockerContainer(image.tag) as container: - wait_for_logs(container, "Hello from Docker Image!") + with DockerImage(path=temp_directory, tag=test_image_tag, clean_up=test_cleanup) as image: + image_short_id = image.short_id + assert image.tag is test_image_tag, f"Expected {test_image_tag}, got {image.tag}" + assert image.short_id is not None, "Short ID should not be None" + logs = image.get_logs() + assert isinstance(logs, list), "Logs should be a list" + assert logs[0] == {"stream": "Step 1/2 : FROM alpine:latest"} + assert logs[3] == {"stream": f'Step 2/2 : CMD echo "{random_string}"'} + with DockerContainer(str(image)) as container: + assert container._container.image.short_id.endswith(image_short_id), "Image ID mismatch" + assert container.get_logs() == ((random_string + "\n").encode(), b""), "Container logs mismatch" + + check_for_image(image_short_id, test_cleanup) diff --git a/core/tests/test_generics.py b/core/tests/test_generics.py new file mode 100644 index 000000000..c6f7fde7d --- /dev/null +++ b/core/tests/test_generics.py @@ -0,0 +1,27 @@ +import pytest +from typing import Optional +from testcontainers.core.generic import SrvContainer + +import re + + +@pytest.mark.parametrize("test_image_cleanup", [True, False]) +@pytest.mark.parametrize("test_image_tag", [None, "custom-image:test"]) +def test_srv_container(test_image_tag: Optional[str], test_image_cleanup: bool, check_for_image, port=9000): + with SrvContainer( + path="./core/tests/image_fixtures/python_server", + port=port, + tag=test_image_tag, + image_cleanup=test_image_cleanup, + ) as srv: + image_short_id = srv.docker_image.short_id + image_build_logs = srv.docker_image.get_logs() + # check if dict is in any of the logs + assert {"stream": f"Step 2/3 : EXPOSE {port}"} in image_build_logs, "Image logs mismatch" + assert (port, None) in srv.ports.items(), "Port mismatch" + with pytest.raises(NotImplementedError): + srv.get_api_url() + test_url = srv._create_connection_url() + assert re.match(r"http://localhost:\d+", test_url), "Connection URL mismatch" + + check_for_image(image_short_id, test_image_cleanup) From 8ef441a3fc2ecbca08767df01dcddc0acf6cde91 Mon Sep 17 00:00:00 2001 From: Roy Moore Date: Mon, 13 May 2024 21:16:48 +0000 Subject: [PATCH 3/4] feat(core): Added FastAPI module Fix missing doc in index Fix extra Fix missing httpx as optional dependency --- index.rst | 1 + modules/fastapi/README.rst | 2 + .../testcontainers/fastapi/__init__.py | 43 +++++++++++++++++++ modules/fastapi/tests/sample/Dockerfile | 11 +++++ modules/fastapi/tests/sample/app/__init__.py | 0 modules/fastapi/tests/sample/app/main.py | 8 ++++ modules/fastapi/tests/test_fastapi.py | 33 ++++++++++++++ poetry.lock | 3 +- pyproject.toml | 5 ++- 9 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 modules/fastapi/README.rst create mode 100644 modules/fastapi/testcontainers/fastapi/__init__.py create mode 100644 modules/fastapi/tests/sample/Dockerfile create mode 100644 modules/fastapi/tests/sample/app/__init__.py create mode 100644 modules/fastapi/tests/sample/app/main.py create mode 100644 modules/fastapi/tests/test_fastapi.py diff --git a/index.rst b/index.rst index 3c7fcc140..eccaeef51 100644 --- a/index.rst +++ b/index.rst @@ -21,6 +21,7 @@ testcontainers-python facilitates the use of Docker containers for functional an modules/chroma/README modules/clickhouse/README modules/elasticsearch/README + modules/fastapi/README modules/google/README modules/influxdb/README modules/k3s/README diff --git a/modules/fastapi/README.rst b/modules/fastapi/README.rst new file mode 100644 index 000000000..8783883da --- /dev/null +++ b/modules/fastapi/README.rst @@ -0,0 +1,2 @@ +.. autoclass:: testcontainers.fastapi.FastAPIContainer +.. title:: testcontainers.fastapi.FastAPIContainer diff --git a/modules/fastapi/testcontainers/fastapi/__init__.py b/modules/fastapi/testcontainers/fastapi/__init__.py new file mode 100644 index 000000000..d2186d9f8 --- /dev/null +++ b/modules/fastapi/testcontainers/fastapi/__init__.py @@ -0,0 +1,43 @@ +from typing import Optional + +import httpx + +from testcontainers.core.generic import SrvContainer + + +class FastAPIContainer(SrvContainer): + """ + FastAPI container that is based on a custom image. + + Example: + + .. doctest:: + + >>> from testcontainers.fastapi import FastAPIContainer + >>> from testcontainers.core.waiting_utils import wait_for_logs + + >>> with FastAPIContainer(path="./modules/fastapi/tests/sample", port=80, tag="fastapi:latest") as fastapi: + ... delay = wait_for_logs(fastapi, "Uvicorn running on http://0.0.0.0:80") + ... client = fastapi.get_client() + ... response = client.get("/") + ... assert response.status_code == 200 + ... assert response.json() == {"Status": "Working"} + """ + + def __init__(self, path: str, port: int, tag: Optional[str] = None, image_cleanup: bool = True) -> None: + """ + :param path: Path to the FastAPI application. + :param port: Port to expose the FastAPI application. + :param tag: Tag for the image to be built (default: None). + :param image_cleanup: Clean up the image after the container is stopped (default: True). + """ + super().__init__(path, port, tag, image_cleanup) + + def get_api_url(self) -> str: + return self._create_connection_url() + "/api/v1/" + + def get_client(self) -> httpx.Client: + return httpx.Client(base_url=self.get_api_url()) + + def get_stdout(self) -> str: + return self.get_logs()[0].decode("utf-8") diff --git a/modules/fastapi/tests/sample/Dockerfile b/modules/fastapi/tests/sample/Dockerfile new file mode 100644 index 000000000..f56288cd5 --- /dev/null +++ b/modules/fastapi/tests/sample/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.9 + +WORKDIR /app + +RUN pip install fastapi + +COPY ./app /app + +EXPOSE 80 + +CMD ["fastapi", "run", "main.py", "--port", "80"] diff --git a/modules/fastapi/tests/sample/app/__init__.py b/modules/fastapi/tests/sample/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/modules/fastapi/tests/sample/app/main.py b/modules/fastapi/tests/sample/app/main.py new file mode 100644 index 000000000..f96073d9f --- /dev/null +++ b/modules/fastapi/tests/sample/app/main.py @@ -0,0 +1,8 @@ +from fastapi import FastAPI + +app = FastAPI() + + +@app.get("/api/v1/") +def read_root(): + return {"Status": "Working"} diff --git a/modules/fastapi/tests/test_fastapi.py b/modules/fastapi/tests/test_fastapi.py new file mode 100644 index 000000000..b6bbf7f23 --- /dev/null +++ b/modules/fastapi/tests/test_fastapi.py @@ -0,0 +1,33 @@ +import re +import pytest + +from testcontainers.fastapi import FastAPIContainer + + +def test_fastapi_container(): + with FastAPIContainer( + path="./modules/fastapi/tests/sample", port=80, tag="fastapi:test", image_cleanup=False + ) as fastapi: + assert fastapi.get_container_host_ip() == "localhost" + assert fastapi.internal_port == 80 + assert re.match(r"http://localhost:\d+/api/v1/", fastapi.get_api_url()) + assert fastapi.get_client().get("/").status_code == 200 + assert fastapi.get_client().get("/").json() == {"Status": "Working"} + + +def test_fastapi_container_no_tag(): + with FastAPIContainer(path="./modules/fastapi/tests/sample", port=80, image_cleanup=False) as fastapi: + assert fastapi.get_client().get("/").status_code == 200 + assert fastapi.get_client().get("/").json() == {"Status": "Working"} + + +def test_fastapi_container_no_port(): + with pytest.raises(TypeError): + with FastAPIContainer(path="./modules/fastapi/tests/sample", tag="fastapi:test", image_cleanup=False): + pass + + +def test_fastapi_container_no_path(): + with pytest.raises(TypeError): + with FastAPIContainer(port=80, tag="fastapi:test", image_cleanup=True): + pass diff --git a/poetry.lock b/poetry.lock index 272b0b238..7cb910630 100644 --- a/poetry.lock +++ b/poetry.lock @@ -4183,6 +4183,7 @@ cassandra = [] chroma = ["chromadb-client"] clickhouse = ["clickhouse-driver"] elasticsearch = [] +fastapi = ["httpx"] google = ["google-cloud-datastore", "google-cloud-pubsub"] influxdb = ["influxdb", "influxdb-client"] k3s = ["kubernetes", "pyyaml"] @@ -4212,4 +4213,4 @@ weaviate = ["weaviate-client"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<4.0" -content-hash = "95a2e0ef23d8dfb1cbc74d72f534028aeff5da8bc26cc194f464f6fe282ba38f" +content-hash = "8676827f49237bd96b95c4cf9686728d401bddf78d09a2e3e018ac2dc882a8a9" diff --git a/pyproject.toml b/pyproject.toml index 2594f3e44..3d23423ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ packages = [ { include = "testcontainers", from = "modules/chroma" }, { include = "testcontainers", from = "modules/clickhouse" }, { include = "testcontainers", from = "modules/elasticsearch" }, + { include = "testcontainers", from = "modules/fastapi" }, { include = "testcontainers", from = "modules/google" }, { include = "testcontainers", from = "modules/influxdb" }, { include = "testcontainers", from = "modules/k3s" }, @@ -57,7 +58,7 @@ packages = [ { include = "testcontainers", from = "modules/registry" }, { include = "testcontainers", from = "modules/selenium" }, { include = "testcontainers", from = "modules/vault" }, - { include = "testcontainers", from = "modules/weaviate" } + { include = "testcontainers", from = "modules/weaviate" }, ] [tool.poetry.urls] @@ -99,6 +100,7 @@ weaviate-client = { version = "^4.5.4", optional = true } chromadb-client = { version = "*", optional = true } qdrant-client = { version = "*", optional = true } bcrypt = { version = "*", optional = true } +httpx = { version = "*", optional = true } [tool.poetry.extras] arangodb = ["python-arango"] @@ -106,6 +108,7 @@ azurite = ["azure-storage-blob"] cassandra = [] clickhouse = ["clickhouse-driver"] elasticsearch = [] +fastapi = ["httpx"] google = ["google-cloud-pubsub", "google-cloud-datastore"] influxdb = ["influxdb", "influxdb-client"] k3s = ["kubernetes", "pyyaml"] From 6bf08ab43e26942928ea06ab2568d01130290936 Mon Sep 17 00:00:00 2001 From: Roy Moore Date: Wed, 15 May 2024 17:46:32 +0000 Subject: [PATCH 4/4] feat(core): Added AWS Lambda module --- index.rst | 1 + modules/aws/README.rst | 6 ++ modules/aws/testcontainers/aws/__init__.py | 1 + modules/aws/testcontainers/aws/aws_lambda.py | 65 +++++++++++++++++++ modules/aws/tests/lambda_sample/Dockerfile | 10 +++ .../tests/lambda_sample/lambda_function.py | 5 ++ modules/aws/tests/test_aws.py | 35 ++++++++++ poetry.lock | 3 +- pyproject.toml | 2 + 9 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 modules/aws/README.rst create mode 100644 modules/aws/testcontainers/aws/__init__.py create mode 100644 modules/aws/testcontainers/aws/aws_lambda.py create mode 100644 modules/aws/tests/lambda_sample/Dockerfile create mode 100644 modules/aws/tests/lambda_sample/lambda_function.py create mode 100644 modules/aws/tests/test_aws.py diff --git a/index.rst b/index.rst index eccaeef51..301d85d7e 100644 --- a/index.rst +++ b/index.rst @@ -16,6 +16,7 @@ testcontainers-python facilitates the use of Docker containers for functional an core/README modules/arangodb/README + modules/aws/README modules/azurite/README modules/cassandra/README modules/chroma/README diff --git a/modules/aws/README.rst b/modules/aws/README.rst new file mode 100644 index 000000000..409e0ef5d --- /dev/null +++ b/modules/aws/README.rst @@ -0,0 +1,6 @@ +.. autoclass:: testcontainers.aws.AWSLambdaContainer +.. title:: testcontainers.aws.AWSLambdaContainer + +Make sure you are using an image based on `public.ecr.aws/lambda/python` + +Please checkout https://docs.aws.amazon.com/lambda/latest/dg/python-image.html for more information on how to run AWS Lambda functions locally. diff --git a/modules/aws/testcontainers/aws/__init__.py b/modules/aws/testcontainers/aws/__init__.py new file mode 100644 index 000000000..f16705c86 --- /dev/null +++ b/modules/aws/testcontainers/aws/__init__.py @@ -0,0 +1 @@ +from .aws_lambda import AWSLambdaContainer # noqa: F401 diff --git a/modules/aws/testcontainers/aws/aws_lambda.py b/modules/aws/testcontainers/aws/aws_lambda.py new file mode 100644 index 000000000..8ab298854 --- /dev/null +++ b/modules/aws/testcontainers/aws/aws_lambda.py @@ -0,0 +1,65 @@ +import os +from typing import Optional + +import httpx + +from testcontainers.core.generic import SrvContainer + +RIE_PATH = "/2015-03-31/functions/function/invocations" +# AWS OS-only base images contain an Amazon Linux distribution and the runtime interface emulator. + + +class AWSLambdaContainer(SrvContainer): + """ + AWS Lambda container that is based on a custom image. + + Example: + + .. doctest:: + + >>> from testcontainers.aws import AWSLambdaContainer + >>> from testcontainers.core.waiting_utils import wait_for_logs + + >>> with AWSLambdaContainer(path="./modules/aws/tests/lambda_sample", port=8080, tag="lambda_func:latest") as func: + ... response = func.send_request(data={'payload': 'some data'}) + ... assert response.status_code == 200 + ... assert "Hello from AWS Lambda using Python" in response.json() + ... delay = wait_for_logs(func, "START RequestId:") + """ + + def __init__( + self, + path: str, + port: int = 8080, + region_name: Optional[str] = None, + tag: Optional[str] = None, + image_cleanup: bool = True, + ) -> None: + """ + :param path: Path to the AWS Lambda dockerfile. + :param port: Port to be exposed on the container (default: 8080). + :param region_name: AWS region name (default: None). + :param tag: Tag for the image to be built (default: None). + :param image_cleanup: Clean up the image after the container is stopped (default: True). + """ + super().__init__(path, port, tag, image_cleanup) + self.region_name = region_name or os.environ.get("AWS_DEFAULT_REGION", "us-west-1") + self.with_env("AWS_DEFAULT_REGION", self.region_name) + self.with_env("AWS_ACCESS_KEY_ID", "testcontainers-aws") + self.with_env("AWS_SECRET_ACCESS_KEY", "testcontainers-aws") + + def get_api_url(self) -> str: + return self._create_connection_url() + RIE_PATH + + def send_request(self, data: dict) -> httpx.Response: + """ + Send a request to the AWS Lambda function. + + :param data: Data to be sent to the AWS Lambda function. + :return: Response from the AWS Lambda function. + """ + client = httpx.Client() + return client.post(self.get_api_url(), json=data) + + def get_stdout(self) -> str: + return self.get_logs()[0].decode("utf-8") diff --git a/modules/aws/tests/lambda_sample/Dockerfile b/modules/aws/tests/lambda_sample/Dockerfile new file mode 100644 index 000000000..5d071c802 --- /dev/null +++ b/modules/aws/tests/lambda_sample/Dockerfile @@ -0,0 +1,10 @@ +FROM public.ecr.aws/lambda/python:3.9 + +RUN pip install boto3 + +COPY lambda_function.py ${LAMBDA_TASK_ROOT} + +EXPOSE 8080 + +# Set the CMD to your handler (could also be done as a parameter override outside of the Dockerfile) +CMD [ "lambda_function.handler" ] diff --git a/modules/aws/tests/lambda_sample/lambda_function.py b/modules/aws/tests/lambda_sample/lambda_function.py new file mode 100644 index 000000000..b253ed172 --- /dev/null +++ b/modules/aws/tests/lambda_sample/lambda_function.py @@ -0,0 +1,5 @@ +import sys + + +def handler(event, context): + return "Hello from AWS Lambda using Python" + sys.version + "!" diff --git a/modules/aws/tests/test_aws.py b/modules/aws/tests/test_aws.py new file mode 100644 index 000000000..a9bd7eb0c --- /dev/null +++ b/modules/aws/tests/test_aws.py @@ -0,0 +1,35 @@ +import re +import pytest + +from testcontainers.aws import AWSLambdaContainer +from testcontainers.aws.aws_lambda import RIE_PATH + +DOCKER_FILE_PATH = "./modules/aws/tests/lambda_sample" +IMAGE_TAG = "lambda:test" + + +def test_aws_lambda_container(): + with AWSLambdaContainer(path=DOCKER_FILE_PATH, port=8080, tag=IMAGE_TAG, image_cleanup=False) as func: + assert func.get_container_host_ip() == "localhost" + assert func.internal_port == 8080 + assert func.env["AWS_DEFAULT_REGION"] == "us-west-1" + assert func.env["AWS_ACCESS_KEY_ID"] == "testcontainers-aws" + assert func.env["AWS_SECRET_ACCESS_KEY"] == "testcontainers-aws" + assert re.match(rf"http://localhost:\d+{RIE_PATH}", func.get_api_url()) + response = func.send_request(data={"payload": "test"}) + assert response.status_code == 200 + assert "Hello from AWS Lambda using Python" in response.json() + for log_str in ["START RequestId", "END RequestId", "REPORT RequestId"]: + assert log_str in func.get_stdout() + + +def test_aws_lambda_container_no_tag(): + with AWSLambdaContainer(path=DOCKER_FILE_PATH, image_cleanup=True) as func: + response = func.send_request(data={"payload": "test"}) + assert response.status_code == 200 + + +def test_aws_lambda_container_no_path(): + with pytest.raises(TypeError): + with AWSLambdaContainer(port=8080, tag=IMAGE_TAG, image_cleanup=True): + pass diff --git a/poetry.lock b/poetry.lock index 7cb910630..5c4c770d8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -4178,6 +4178,7 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [extras] arangodb = ["python-arango"] +aws = ["boto3", "httpx"] azurite = ["azure-storage-blob"] cassandra = [] chroma = ["chromadb-client"] @@ -4213,4 +4214,4 @@ weaviate = ["weaviate-client"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<4.0" -content-hash = "8676827f49237bd96b95c4cf9686728d401bddf78d09a2e3e018ac2dc882a8a9" +content-hash = "6ffdb1e5432de9dc656cc0b2722ad883d57266b7111f6cab336990ac52a75a2e" diff --git a/pyproject.toml b/pyproject.toml index 3d23423ab..c5df5fd42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ classifiers = [ packages = [ { include = "testcontainers", from = "core" }, { include = "testcontainers", from = "modules/arangodb" }, + { include = "testcontainers", from = "modules/aws"}, { include = "testcontainers", from = "modules/azurite" }, { include = "testcontainers", from = "modules/cassandra" }, { include = "testcontainers", from = "modules/chroma" }, @@ -104,6 +105,7 @@ httpx = { version = "*", optional = true } [tool.poetry.extras] arangodb = ["python-arango"] +aws = ["boto3", "httpx"] azurite = ["azure-storage-blob"] cassandra = [] clickhouse = ["clickhouse-driver"]