Skip to content

Commit

Permalink
feat(core): Added Generic module (#612)
Browse files Browse the repository at this point in the history
As part of the effort described, detailed and presented on
#559
This is the third PR (out of 4) that should provide all the groundwork
to support containers running a server.

As discussed on #595 this PR aims to refactor the `ServerContainer`
under a new dedicated module called "generic".

![image](https://github.com/testcontainers/testcontainers-python/assets/7189138/b7a3395b-ce3c-40ef-8baa-dfa3eff1b056)

The idea is that this module could include multiple generic
implementations such as ```server.py``` with the proper documentation
and examples to allow users simpler usage and QOL.
This PR adds the original FastAPI implementation as a simple doc
example, I think this aligns better following #595
        
Next in line is ```feat(core): Added AWS Lambda module```

Based on the work done on
#585 and
#595
Expended from issue
#83

---
Please note an extra commit is included to simulate the relations when
importing between and with other modules.
  • Loading branch information
Tranquility2 authored Jun 28, 2024
1 parent 3519f4b commit e575b28
Show file tree
Hide file tree
Showing 18 changed files with 232 additions and 89 deletions.
26 changes: 14 additions & 12 deletions core/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,18 @@ Testcontainers Core

.. autoclass:: testcontainers.core.container.DockerContainer

Using `DockerContainer` and `DockerImage` directly:
.. autoclass:: testcontainers.core.image.DockerImage

.. autoclass:: testcontainers.core.generic.DbContainer

.. raw:: html

<hr>

Examples
--------

Using `DockerContainer` and `DockerImage` to create a container:

.. doctest::

Expand All @@ -17,14 +28,5 @@ Using `DockerContainer` and `DockerImage` directly:
... with DockerContainer(str(image)) as container:
... delay = wait_for_logs(container, "Test Sample Image")

---

.. autoclass:: testcontainers.core.image.DockerImage

---

.. autoclass:: testcontainers.core.generic.ServerContainer

---

.. autoclass:: testcontainers.core.generic.DbContainer
The `DockerImage` class is used to build the image from the specified path and tag.
The `DockerContainer` class is then used to create a container from the image.
71 changes: 1 addition & 70 deletions core/testcontainers/core/generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,11 @@
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from typing import Optional, Union
from urllib.error import HTTPError
from typing import Optional
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

Expand Down Expand Up @@ -84,69 +81,3 @@ def _configure(self) -> None:

def _transfer_seed(self) -> None:
pass


class ServerContainer(DockerContainer):
"""
**DEPRECATED - will be moved from core to a module (stay tuned for a final/stable import location)**
Container for a generic server that is based on a custom image.
Example:
.. doctest::
>>> import httpx
>>> from testcontainers.core.generic import ServerContainer
>>> from testcontainers.core.waiting_utils import wait_for_logs
>>> from testcontainers.core.image import DockerImage
>>> with DockerImage(path="./core/tests/image_fixtures/python_server", tag="test-srv:latest") as image:
... with ServerContainer(port=9000, image=image) 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, port: int, image: Union[str, DockerImage]) -> None:
super().__init__(str(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) -> "ServerContainer":
super().start()
self._connect()
return self

def stop(self, force=True, delete_volume=True) -> None:
super().stop(force, delete_volume)
11 changes: 11 additions & 0 deletions index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,17 @@ Please note, that community modules are supported on a best-effort basis and bre
Therefore, only the package core is strictly following SemVer. If your workflow is broken by a minor update, please look at the changelogs for guidance.


Custom Containers
-----------------

Crafting containers that are based on custom images is supported by the `core` module. Please check the `core documentation <core/README.html>`_ for more information.

This allows you to create containers from images that are not part of the modules provided by testcontainers-python.

For common use cases, you can also use the generic containers provided by the `testcontainers-generic` module. Please check the `generic documentation <modules/generic/README.html>`_ for more information.
(example: `ServerContainer` for running a FastAPI server)


Docker in Docker (DinD)
-----------------------

Expand Down
20 changes: 20 additions & 0 deletions modules/generic/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
:code:`testcontainers-generic` is a set of generic containers modules that can be used to creat containers.

.. autoclass:: testcontainers.generic.ServerContainer
.. title:: testcontainers.generic.ServerContainer

FastAPI container that is using :code:`ServerContainer`

.. doctest::

>>> from testcontainers.generic import ServerContainer
>>> from testcontainers.core.waiting_utils import wait_for_logs

>>> with DockerImage(path="./modules/generic/tests/samples/fastapi", tag="fastapi-test:latest") as image:
... with ServerContainer(port=80, image=image) as fastapi_server:
... delay = wait_for_logs(fastapi_server, "Uvicorn running on http://0.0.0.0:80")
... fastapi_server.get_api_url = lambda: fastapi_server._create_connection_url() + "/api/v1/"
... client = fastapi_server.get_client()
... response = client.get("/")
... assert response.status_code == 200
... assert response.json() == {"Status": "Working"}
1 change: 1 addition & 0 deletions modules/generic/testcontainers/generic/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .server import ServerContainer # noqa: F401
80 changes: 80 additions & 0 deletions modules/generic/testcontainers/generic/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
from typing import Union
from urllib.error import HTTPError
from urllib.request import urlopen

import httpx

from testcontainers.core.container import DockerContainer
from testcontainers.core.exceptions import ContainerStartException
from testcontainers.core.image import DockerImage
from testcontainers.core.waiting_utils import wait_container_is_ready


class ServerContainer(DockerContainer):
"""
Container for a generic server that is based on a custom image.
Example:
.. doctest::
>>> import httpx
>>> from testcontainers.generic import ServerContainer
>>> from testcontainers.core.waiting_utils import wait_for_logs
>>> from testcontainers.core.image import DockerImage
>>> with DockerImage(path="./modules/generic/tests/samples/python_server", tag="test-srv:latest") as image:
... with ServerContainer(port=9000, image=image) 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, port: int, image: Union[str, DockerImage]) -> None:
super().__init__(str(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) -> "ServerContainer":
super().start()
self._connect()
return self

def stop(self, force=True, delete_volume=True) -> None:
super().stop(force, delete_volume)

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")
22 changes: 22 additions & 0 deletions modules/generic/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions modules/generic/tests/samples/fastapi/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
Empty file.
8 changes: 8 additions & 0 deletions modules/generic/tests/samples/fastapi/app/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from fastapi import FastAPI

app = FastAPI()


@app.get("/api/v1/")
def read_root():
return {"Status": "Working"}
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,17 @@

from testcontainers.core.waiting_utils import wait_for_logs
from testcontainers.core.image import DockerImage
from testcontainers.core.generic import ServerContainer
from testcontainers.generic import ServerContainer

TEST_DIR = Path(__file__).parent


@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):
def test_server_container(test_image_tag: Optional[str], test_image_cleanup: bool, check_for_image, port=9000):
with (
DockerImage(
path=TEST_DIR / "image_fixtures/python_server",
path=TEST_DIR / "samples/python_server",
tag=test_image_tag,
clean_up=test_image_cleanup,
#
Expand All @@ -37,8 +37,14 @@ def test_srv_container(test_image_tag: Optional[str], test_image_cleanup: bool,
check_for_image(image_short_id, test_image_cleanup)


def test_server_container_no_port():
with pytest.raises(TypeError):
with ServerContainer(path="./modules/generic/tests/samples/python_server", tag="test-srv:latest"):
pass


def test_like_doctest():
with DockerImage(path=TEST_DIR / "image_fixtures/python_server", tag="test-srv:latest") as image:
with DockerImage(path=TEST_DIR / "samples/python_server", tag="test-srv:latest") as image:
with ServerContainer(port=9000, image=image) as srv:
url = srv._create_connection_url()
response = get(f"{url}", timeout=5)
Expand Down
2 changes: 2 additions & 0 deletions modules/testmoduleimport/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.. autoclass:: testcontainers.testmoduleimport.NewSubModuleContainer
.. title:: testcontainers.testmoduleimport.NewSubModuleContainer
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .new_sub_module import NewSubModuleContainer # noqa: F401
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from testcontainers.generic.server import ServerContainer


class NewSubModuleContainer(ServerContainer):
"""
This class is a mock container for testing purposes. It is used to test importing from other modules.
.. doctest::
>>> import httpx
>>> from testcontainers.core.image import DockerImage
>>> from testcontainers.testmoduleimport import NewSubModuleContainer
>>> with DockerImage(path="./modules/generic/tests/samples/python_server", tag="test-mod:latest") as image:
... with NewSubModuleContainer(port=9000, image=image) 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"
... assert srv.print_mock() == "NewSubModuleContainer"
"""

def __init__(self, port: int, image: str) -> None:
super().__init__(port, image)

def print_mock(self) -> str:
return "NewSubModuleContainer"
15 changes: 15 additions & 0 deletions modules/testmoduleimport/tests/test_mock_one.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import httpx

from testcontainers.core.waiting_utils import wait_for_logs
from testcontainers.core.image import DockerImage
from testcontainers.testmoduleimport import NewSubModuleContainer


def test_like_doctest():
with DockerImage(path="./modules/generic/tests/samples/python_server", tag="test-srv:latest") as image:
with NewSubModuleContainer(port=9000, image=image) as srv:
assert srv.print_mock() == "NewSubModuleContainer"
url = srv._create_connection_url()
response = httpx.get(f"{url}", timeout=5)
assert response.status_code == 200, "Response status code is not 200"
_ = wait_for_logs(srv, "GET / HTTP/1.1")
Loading

0 comments on commit e575b28

Please sign in to comment.