Skip to content

Commit e575b28

Browse files
authored
feat(core): Added Generic module (#612)
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.
1 parent 3519f4b commit e575b28

File tree

18 files changed

+232
-89
lines changed

18 files changed

+232
-89
lines changed

core/README.rst

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,18 @@ Testcontainers Core
55

66
.. autoclass:: testcontainers.core.container.DockerContainer
77

8-
Using `DockerContainer` and `DockerImage` directly:
8+
.. autoclass:: testcontainers.core.image.DockerImage
9+
10+
.. autoclass:: testcontainers.core.generic.DbContainer
11+
12+
.. raw:: html
13+
14+
<hr>
15+
16+
Examples
17+
--------
18+
19+
Using `DockerContainer` and `DockerImage` to create a container:
920

1021
.. doctest::
1122

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

20-
---
21-
22-
.. autoclass:: testcontainers.core.image.DockerImage
23-
24-
---
25-
26-
.. autoclass:: testcontainers.core.generic.ServerContainer
27-
28-
---
29-
30-
.. autoclass:: testcontainers.core.generic.DbContainer
31+
The `DockerImage` class is used to build the image from the specified path and tag.
32+
The `DockerContainer` class is then used to create a container from the image.

core/testcontainers/core/generic.py

Lines changed: 1 addition & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,11 @@
1010
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
1111
# License for the specific language governing permissions and limitations
1212
# under the License.
13-
from typing import Optional, Union
14-
from urllib.error import HTTPError
13+
from typing import Optional
1514
from urllib.parse import quote
16-
from urllib.request import urlopen
1715

1816
from testcontainers.core.container import DockerContainer
1917
from testcontainers.core.exceptions import ContainerStartException
20-
from testcontainers.core.image import DockerImage
2118
from testcontainers.core.utils import raise_for_deprecated_parameter
2219
from testcontainers.core.waiting_utils import wait_container_is_ready
2320

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

8582
def _transfer_seed(self) -> None:
8683
pass
87-
88-
89-
class ServerContainer(DockerContainer):
90-
"""
91-
**DEPRECATED - will be moved from core to a module (stay tuned for a final/stable import location)**
92-
93-
Container for a generic server that is based on a custom image.
94-
95-
Example:
96-
97-
.. doctest::
98-
99-
>>> import httpx
100-
>>> from testcontainers.core.generic import ServerContainer
101-
>>> from testcontainers.core.waiting_utils import wait_for_logs
102-
>>> from testcontainers.core.image import DockerImage
103-
104-
>>> with DockerImage(path="./core/tests/image_fixtures/python_server", tag="test-srv:latest") as image:
105-
... with ServerContainer(port=9000, image=image) as srv:
106-
... url = srv._create_connection_url()
107-
... response = httpx.get(f"{url}", timeout=5)
108-
... assert response.status_code == 200, "Response status code is not 200"
109-
... delay = wait_for_logs(srv, "GET / HTTP/1.1")
110-
111-
112-
:param path: Path to the Dockerfile to build the image
113-
:param tag: Tag for the image to be built (default: None)
114-
"""
115-
116-
def __init__(self, port: int, image: Union[str, DockerImage]) -> None:
117-
super().__init__(str(image))
118-
self.internal_port = port
119-
self.with_exposed_ports(self.internal_port)
120-
121-
@wait_container_is_ready(HTTPError)
122-
def _connect(self) -> None:
123-
# noinspection HttpUrlsUsage
124-
url = self._create_connection_url()
125-
try:
126-
with urlopen(url) as r:
127-
assert b"" in r.read()
128-
except HTTPError as e:
129-
# 404 is expected, as the server may not have the specific endpoint we are looking for
130-
if e.code == 404:
131-
pass
132-
else:
133-
raise
134-
135-
def get_api_url(self) -> str:
136-
raise NotImplementedError
137-
138-
def _create_connection_url(self) -> str:
139-
if self._container is None:
140-
raise ContainerStartException("container has not been started")
141-
host = self.get_container_host_ip()
142-
exposed_port = self.get_exposed_port(self.internal_port)
143-
url = f"http://{host}:{exposed_port}"
144-
return url
145-
146-
def start(self) -> "ServerContainer":
147-
super().start()
148-
self._connect()
149-
return self
150-
151-
def stop(self, force=True, delete_volume=True) -> None:
152-
super().stop(force, delete_volume)

index.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,17 @@ Please note, that community modules are supported on a best-effort basis and bre
7070
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.
7171

7272

73+
Custom Containers
74+
-----------------
75+
76+
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.
77+
78+
This allows you to create containers from images that are not part of the modules provided by testcontainers-python.
79+
80+
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.
81+
(example: `ServerContainer` for running a FastAPI server)
82+
83+
7384
Docker in Docker (DinD)
7485
-----------------------
7586

modules/generic/README.rst

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
:code:`testcontainers-generic` is a set of generic containers modules that can be used to creat containers.
2+
3+
.. autoclass:: testcontainers.generic.ServerContainer
4+
.. title:: testcontainers.generic.ServerContainer
5+
6+
FastAPI container that is using :code:`ServerContainer`
7+
8+
.. doctest::
9+
10+
>>> from testcontainers.generic import ServerContainer
11+
>>> from testcontainers.core.waiting_utils import wait_for_logs
12+
13+
>>> with DockerImage(path="./modules/generic/tests/samples/fastapi", tag="fastapi-test:latest") as image:
14+
... with ServerContainer(port=80, image=image) as fastapi_server:
15+
... delay = wait_for_logs(fastapi_server, "Uvicorn running on http://0.0.0.0:80")
16+
... fastapi_server.get_api_url = lambda: fastapi_server._create_connection_url() + "/api/v1/"
17+
... client = fastapi_server.get_client()
18+
... response = client.get("/")
19+
... assert response.status_code == 200
20+
... assert response.json() == {"Status": "Working"}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .server import ServerContainer # noqa: F401
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
from typing import Union
2+
from urllib.error import HTTPError
3+
from urllib.request import urlopen
4+
5+
import httpx
6+
7+
from testcontainers.core.container import DockerContainer
8+
from testcontainers.core.exceptions import ContainerStartException
9+
from testcontainers.core.image import DockerImage
10+
from testcontainers.core.waiting_utils import wait_container_is_ready
11+
12+
13+
class ServerContainer(DockerContainer):
14+
"""
15+
Container for a generic server that is based on a custom image.
16+
17+
Example:
18+
19+
.. doctest::
20+
21+
>>> import httpx
22+
>>> from testcontainers.generic import ServerContainer
23+
>>> from testcontainers.core.waiting_utils import wait_for_logs
24+
>>> from testcontainers.core.image import DockerImage
25+
26+
>>> with DockerImage(path="./modules/generic/tests/samples/python_server", tag="test-srv:latest") as image:
27+
... with ServerContainer(port=9000, image=image) as srv:
28+
... url = srv._create_connection_url()
29+
... response = httpx.get(f"{url}", timeout=5)
30+
... assert response.status_code == 200, "Response status code is not 200"
31+
... delay = wait_for_logs(srv, "GET / HTTP/1.1")
32+
33+
34+
:param path: Path to the Dockerfile to build the image
35+
:param tag: Tag for the image to be built (default: None)
36+
"""
37+
38+
def __init__(self, port: int, image: Union[str, DockerImage]) -> None:
39+
super().__init__(str(image))
40+
self.internal_port = port
41+
self.with_exposed_ports(self.internal_port)
42+
43+
@wait_container_is_ready(HTTPError)
44+
def _connect(self) -> None:
45+
# noinspection HttpUrlsUsage
46+
url = self._create_connection_url()
47+
try:
48+
with urlopen(url) as r:
49+
assert b"" in r.read()
50+
except HTTPError as e:
51+
# 404 is expected, as the server may not have the specific endpoint we are looking for
52+
if e.code == 404:
53+
pass
54+
else:
55+
raise
56+
57+
def get_api_url(self) -> str:
58+
raise NotImplementedError
59+
60+
def _create_connection_url(self) -> str:
61+
if self._container is None:
62+
raise ContainerStartException("container has not been started")
63+
host = self.get_container_host_ip()
64+
exposed_port = self.get_exposed_port(self.internal_port)
65+
url = f"http://{host}:{exposed_port}"
66+
return url
67+
68+
def start(self) -> "ServerContainer":
69+
super().start()
70+
self._connect()
71+
return self
72+
73+
def stop(self, force=True, delete_volume=True) -> None:
74+
super().stop(force, delete_volume)
75+
76+
def get_client(self) -> httpx.Client:
77+
return httpx.Client(base_url=self.get_api_url())
78+
79+
def get_stdout(self) -> str:
80+
return self.get_logs()[0].decode("utf-8")

modules/generic/tests/conftest.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import pytest
2+
from typing import Callable
3+
from testcontainers.core.container import DockerClient
4+
5+
6+
@pytest.fixture
7+
def check_for_image() -> Callable[[str, bool], None]:
8+
"""Warp the check_for_image function in a fixture"""
9+
10+
def _check_for_image(image_short_id: str, cleaned: bool) -> None:
11+
"""
12+
Validates if the image is present or not.
13+
14+
:param image_short_id: The short id of the image
15+
:param cleaned: True if the image should not be present, False otherwise
16+
"""
17+
client = DockerClient()
18+
images = client.client.images.list()
19+
found = any(image.short_id.endswith(image_short_id) for image in images)
20+
assert found is not cleaned, f'Image {image_short_id} was {"found" if cleaned else "not found"}'
21+
22+
return _check_for_image
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
FROM python:3.9
2+
3+
WORKDIR /app
4+
5+
RUN pip install fastapi
6+
7+
COPY ./app /app
8+
9+
EXPOSE 80
10+
11+
CMD ["fastapi", "run", "main.py", "--port", "80"]

modules/generic/tests/samples/fastapi/app/__init__.py

Whitespace-only changes.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from fastapi import FastAPI
2+
3+
app = FastAPI()
4+
5+
6+
@app.get("/api/v1/")
7+
def read_root():
8+
return {"Status": "Working"}

0 commit comments

Comments
 (0)