Skip to content

Commit 6f140f1

Browse files
committed
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
1 parent b577d3f commit 6f140f1

File tree

9 files changed

+175
-24
lines changed

9 files changed

+175
-24
lines changed

core/README.rst

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,16 @@ testcontainers-core
77

88
.. autoclass:: testcontainers.core.image.DockerImage
99

10-
Conjoint usage:
10+
.. autoclass:: testcontainers.core.generic.SrvContainer
11+
12+
Using `DockerContainer` and `DockerImage` directly:
1113

1214
.. doctest::
15+
1316
>>> from testcontainers.core.container import DockerContainer
1417
>>> from testcontainers.core.waiting_utils import wait_for_logs
1518
>>> from testcontainers.core.image import DockerImage
1619

17-
>>> with DockerImage(tag="test-image:latest", path=".") as image:
18-
... with DockerContainer(tag=image.tag) as container:
19-
... delay = wait_for_log(container, "Server ready", 60)
20+
>>> with DockerImage(path="./core/tests/image_fixtures/sample/", tag="test-sample:latest") as image:
21+
... with DockerContainer(str(image)) as container:
22+
... delay = wait_for_logs(container, "Test Sample Image")

core/testcontainers/core/generic.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,13 @@
1111
# License for the specific language governing permissions and limitations
1212
# under the License.
1313
from typing import Optional
14+
from urllib.error import HTTPError
1415
from urllib.parse import quote
16+
from urllib.request import urlopen
1517

1618
from testcontainers.core.container import DockerContainer
1719
from testcontainers.core.exceptions import ContainerStartException
20+
from testcontainers.core.image import DockerImage
1821
from testcontainers.core.utils import raise_for_deprecated_parameter
1922
from testcontainers.core.waiting_utils import wait_container_is_ready
2023

@@ -79,3 +82,67 @@ def _configure(self) -> None:
7982

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

core/testcontainers/core/image.py

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,44 +16,61 @@ class DockerImage:
1616
Basic image object to build Docker images.
1717
1818
.. doctest::
19+
1920
>>> from testcontainers.core.image import DockerImage
20-
>>> with DockerImage(tag="new_image", path=".") as image:
21+
>>> with DockerImage(path="./core/tests/image_fixtures/sample/", tag="test-image") as image:
2122
... logs = image.get_logs()
2223
23-
:param tag: Tag of the image
24-
:param path: Path to the Dockerfile
24+
:param tag: Tag for the image to be built (default: None)
25+
:param path: Path to the Dockerfile to build the image
2526
"""
2627

2728
def __init__(
2829
self,
29-
tag: str,
3030
path: str,
3131
docker_client_kw: Optional[dict] = None,
32+
tag: Optional[str] = None,
33+
clean_up: bool = True,
3234
**kwargs,
3335
) -> None:
3436
self.tag = tag
3537
self.path = path
38+
self.id = None
3639
self._docker = DockerClient(**(docker_client_kw or {}))
40+
self.clean_up = clean_up
3741
self._kwargs = kwargs
3842

3943
def build(self, **kwargs) -> Self:
40-
logger.info(f"Building image {self.tag} from {self.path}")
44+
logger.info(f"Building image from {self.path}")
4145
docker_client = self.get_docker_client()
4246
self._image, self._logs = docker_client.build(path=self.path, tag=self.tag, **kwargs)
43-
logger.info(f"Built image {self.tag}")
47+
logger.info(f"Built image {self.short_id} with tag {self.tag}")
4448
return self
4549

50+
@property
51+
def short_id(self) -> str:
52+
"""
53+
The ID of the image truncated to 12 characters, without the ``sha256:`` prefix.
54+
"""
55+
if self._image.id.startswith("sha256:"):
56+
return self._image.id.split(":")[1][:12]
57+
return self._image.id[:12]
58+
4659
def remove(self, force=True, noprune=False) -> None:
4760
"""
4861
Remove the image.
4962
5063
:param force: Remove the image even if it is in use
5164
:param noprune: Do not delete untagged parent images
5265
"""
53-
if self._image:
66+
if self._image and self.clean_up:
67+
logger.info(f"Removing image {self.short_id}")
5468
self._image.remove(force=force, noprune=noprune)
5569
self.get_docker_client().client.close()
5670

71+
def __str__(self) -> str:
72+
return f"{self.tag if self.tag else self.short_id}"
73+
5774
def __enter__(self) -> Self:
5875
return self.build()
5976

core/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
File renamed without changes.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
FROM python:3
2+
EXPOSE 9000
3+
CMD ["python", "-m", "http.server", "9000"]
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
FROM alpine:latest
2+
CMD echo "Test Sample Image"

core/tests/test_core.py

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import pytest
22
import tempfile
3+
import random
4+
5+
from typing import Optional
36

47
from testcontainers.core.container import DockerContainer
58
from testcontainers.core.image import DockerImage
@@ -35,22 +38,29 @@ def test_can_get_logs():
3538
assert stdout, "There should be something on stdout"
3639

3740

38-
def test_from_image():
39-
test_image_tag = "test-image:latest"
41+
@pytest.mark.parametrize("test_cleanup", [True, False])
42+
@pytest.mark.parametrize("test_image_tag", [None, "test-image:latest"])
43+
def test_docker_image(test_image_tag: Optional[str], test_cleanup: bool, check_for_image):
4044
with tempfile.TemporaryDirectory() as temp_directory:
45+
# It's important to use a random string to avoid image caching
46+
random_string = "Hello from Docker Image! " + str(random.randint(0, 1000))
4147
with open(f"{temp_directory}/Dockerfile", "w") as f:
4248
f.write(
43-
"""
49+
f"""
4450
FROM alpine:latest
45-
CMD echo "Hello from Docker Image!"
51+
CMD echo "{random_string}"
4652
"""
4753
)
48-
f.close()
49-
with DockerImage(tag=test_image_tag, path=temp_directory) as image:
50-
assert image.tag == test_image_tag
51-
logs = image.get_logs()
52-
assert isinstance(logs, list)
53-
assert logs[0] == {"stream": "Step 1/2 : FROM alpine:latest"}
54-
assert logs[3] == {"stream": 'Step 2/2 : CMD echo "Hello from Docker Image!"'}
55-
with DockerContainer(image.tag) as container:
56-
wait_for_logs(container, "Hello from Docker Image!")
54+
with DockerImage(path=temp_directory, tag=test_image_tag, clean_up=test_cleanup) as image:
55+
image_short_id = image.short_id
56+
assert image.tag is test_image_tag, f"Expected {test_image_tag}, got {image.tag}"
57+
assert image.short_id is not None, "Short ID should not be None"
58+
logs = image.get_logs()
59+
assert isinstance(logs, list), "Logs should be a list"
60+
assert logs[0] == {"stream": "Step 1/2 : FROM alpine:latest"}
61+
assert logs[3] == {"stream": f'Step 2/2 : CMD echo "{random_string}"'}
62+
with DockerContainer(str(image)) as container:
63+
assert container._container.image.short_id.endswith(image_short_id), "Image ID mismatch"
64+
assert container.get_logs() == ((random_string + "\n").encode(), b""), "Container logs mismatch"
65+
66+
check_for_image(image_short_id, test_cleanup)

core/tests/test_generics.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import pytest
2+
from typing import Optional
3+
from testcontainers.core.generic import SrvContainer
4+
5+
import re
6+
7+
8+
@pytest.mark.parametrize("test_image_cleanup", [True, False])
9+
@pytest.mark.parametrize("test_image_tag", [None, "custom-image:test"])
10+
def test_srv_container(test_image_tag: Optional[str], test_image_cleanup: bool, check_for_image, port=9000):
11+
with SrvContainer(
12+
path="./core/tests/image_fixtures/python_server",
13+
port=port,
14+
tag=test_image_tag,
15+
image_cleanup=test_image_cleanup,
16+
) as srv:
17+
image_short_id = srv.docker_image.short_id
18+
image_build_logs = srv.docker_image.get_logs()
19+
# check if dict is in any of the logs
20+
assert {"stream": f"Step 2/3 : EXPOSE {port}"} in image_build_logs, "Image logs mismatch"
21+
assert (port, None) in srv.ports.items(), "Port mismatch"
22+
with pytest.raises(NotImplementedError):
23+
srv.get_api_url()
24+
test_url = srv._create_connection_url()
25+
assert re.match(r"http://localhost:\d+", test_url), "Connection URL mismatch"
26+
27+
check_for_image(image_short_id, test_image_cleanup)

0 commit comments

Comments
 (0)