Skip to content

Commit 54c88cf

Browse files
authored
feat(core): Image build (Dockerfile support) (#585)
As part of the effort described, detailed and presented on #559 (Providing the implementation for #83 - Docker file support and more) This is the first PR (out of 4) that should provide all the groundwork to support image build. This would allow users to use custom images: ```python with DockerImage(path=".") as image: with DockerContainer(str(image)) as container: # Test something with/on custom image ``` Next in line is: `feat(core): Added SrvContainer` And later on: `feat(core): Added FastAPI module` `feat(core): Added AWS Lambda module` (all of the above can be overviewed on #559)
1 parent a95af7d commit 54c88cf

File tree

8 files changed

+190
-0
lines changed

8 files changed

+190
-0
lines changed

core/README.rst

+14
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,17 @@ testcontainers-core
44
:code:`testcontainers-core` is the core functionality for spinning up Docker containers in test environments.
55

66
.. autoclass:: testcontainers.core.container.DockerContainer
7+
8+
.. autoclass:: testcontainers.core.image.DockerImage
9+
10+
Using `DockerContainer` and `DockerImage` directly:
11+
12+
.. doctest::
13+
14+
>>> from testcontainers.core.container import DockerContainer
15+
>>> from testcontainers.core.waiting_utils import wait_for_logs
16+
>>> from testcontainers.core.image import DockerImage
17+
18+
>>> with DockerImage(path="./core/tests/image_fixtures/sample/", tag="test-sample:latest") as image:
19+
... with DockerContainer(str(image)) as container:
20+
... delay = wait_for_logs(container, "Test Sample Image")

core/testcontainers/core/docker_client.py

+21
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@
1616
import os
1717
import urllib
1818
import urllib.parse
19+
from collections.abc import Iterable
1920
from typing import Callable, Optional, TypeVar, Union
2021

2122
import docker
2223
from docker.models.containers import Container, ContainerCollection
24+
from docker.models.images import Image, ImageCollection
2325
from typing_extensions import ParamSpec
2426

2527
from testcontainers.core.config import testcontainers_config as c
@@ -40,6 +42,14 @@ def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T:
4042
return wrapper
4143

4244

45+
def _wrapped_image_collection(function: Callable[_P, _T]) -> Callable[_P, _T]:
46+
@ft.wraps(ImageCollection.build)
47+
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T:
48+
return function(*args, **kwargs)
49+
50+
return wrapper
51+
52+
4353
class DockerClient:
4454
"""
4555
Thin wrapper around :class:`docker.DockerClient` for a more functional interface.
@@ -94,6 +104,17 @@ def run(
94104
)
95105
return container
96106

107+
@_wrapped_image_collection
108+
def build(self, path: str, tag: str, rm: bool = True, **kwargs) -> tuple[Image, Iterable[dict]]:
109+
"""
110+
Build a Docker image from a directory containing the Dockerfile.
111+
112+
:return: A tuple containing the image object and the build logs.
113+
"""
114+
image_object, image_logs = self.client.images.build(path=path, tag=tag, rm=rm, **kwargs)
115+
116+
return image_object, image_logs
117+
97118
def find_host_network(self) -> Optional[str]:
98119
"""
99120
Try to find the docker host network.

core/testcontainers/core/image.py

+88
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
from typing import TYPE_CHECKING, Optional
2+
3+
from typing_extensions import Self
4+
5+
from testcontainers.core.docker_client import DockerClient
6+
from testcontainers.core.utils import setup_logger
7+
8+
if TYPE_CHECKING:
9+
from docker.models.containers import Image
10+
11+
logger = setup_logger(__name__)
12+
13+
14+
class DockerImage:
15+
"""
16+
Basic image object to build Docker images.
17+
18+
.. doctest::
19+
20+
>>> from testcontainers.core.image import DockerImage
21+
22+
>>> with DockerImage(path="./core/tests/image_fixtures/sample/", tag="test-image") as image:
23+
... logs = image.get_logs()
24+
25+
:param tag: Tag for the image to be built (default: None)
26+
:param path: Path to the Dockerfile to build the image
27+
"""
28+
29+
def __init__(
30+
self,
31+
path: str,
32+
docker_client_kw: Optional[dict] = None,
33+
tag: Optional[str] = None,
34+
clean_up: bool = True,
35+
**kwargs,
36+
) -> None:
37+
self.tag = tag
38+
self.path = path
39+
self.id = None
40+
self._docker = DockerClient(**(docker_client_kw or {}))
41+
self.clean_up = clean_up
42+
self._kwargs = kwargs
43+
44+
def build(self, **kwargs) -> Self:
45+
logger.info(f"Building image from {self.path}")
46+
docker_client = self.get_docker_client()
47+
self._image, self._logs = docker_client.build(path=self.path, tag=self.tag, **kwargs)
48+
logger.info(f"Built image {self.short_id} with tag {self.tag}")
49+
return self
50+
51+
@property
52+
def short_id(self) -> str:
53+
"""
54+
The ID of the image truncated to 12 characters, without the ``sha256:`` prefix.
55+
"""
56+
if self._image.id.startswith("sha256:"):
57+
return self._image.id.split(":")[1][:12]
58+
return self._image.id[:12]
59+
60+
def remove(self, force=True, noprune=False) -> None:
61+
"""
62+
Remove the image.
63+
64+
:param force: Remove the image even if it is in use
65+
:param noprune: Do not delete untagged parent images
66+
"""
67+
if self._image and self.clean_up:
68+
logger.info(f"Removing image {self.short_id}")
69+
self._image.remove(force=force, noprune=noprune)
70+
self.get_docker_client().client.close()
71+
72+
def __str__(self) -> str:
73+
return f"{self.tag if self.tag else self.short_id}"
74+
75+
def __enter__(self) -> Self:
76+
return self.build()
77+
78+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
79+
self.remove()
80+
81+
def get_wrapped_image(self) -> "Image":
82+
return self._image
83+
84+
def get_docker_client(self) -> DockerClient:
85+
return self._docker
86+
87+
def get_logs(self) -> list[dict]:
88+
return list(self._logs)

core/tests/conftest.py

+22
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.
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

+33
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import pytest
2+
import tempfile
3+
import random
4+
5+
from typing import Optional
26

37
from testcontainers.core.container import DockerContainer
8+
from testcontainers.core.image import DockerImage
49
from testcontainers.core.waiting_utils import wait_for_logs
510

611

@@ -31,3 +36,31 @@ def test_can_get_logs():
3136
assert isinstance(stdout, bytes)
3237
assert isinstance(stderr, bytes)
3338
assert stdout, "There should be something on stdout"
39+
40+
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):
44+
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))
47+
with open(f"{temp_directory}/Dockerfile", "w") as f:
48+
f.write(
49+
f"""
50+
FROM alpine:latest
51+
CMD echo "{random_string}"
52+
"""
53+
)
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_docker_client.py

+10
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from testcontainers.core.container import DockerContainer
1010
from testcontainers.core.docker_client import DockerClient
1111
from testcontainers.core.utils import parse_docker_auth_config
12+
from testcontainers.core.image import DockerImage
1213

1314

1415
def test_docker_client_from_env():
@@ -54,3 +55,12 @@ def test_container_docker_client_kw():
5455
DockerContainer(image="", docker_client_kw=test_kwargs)
5556

5657
mock_docker.from_env.assert_called_with(**test_kwargs)
58+
59+
60+
def test_image_docker_client_kw():
61+
test_kwargs = {"test_kw": "test_value"}
62+
mock_docker = MagicMock(spec=docker)
63+
with patch("testcontainers.core.docker_client.docker", mock_docker):
64+
DockerImage(name="", path="", docker_client_kw=test_kwargs)
65+
66+
mock_docker.from_env.assert_called_with(**test_kwargs)

0 commit comments

Comments
 (0)