Skip to content

feat(core): Added Generic module #612

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jun 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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