Skip to content
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

feat(core): add support for direct Dockerfile builds #455

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions core/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ testcontainers-core
:code:`testcontainers-core` is the core functionality for spinning up Docker containers in test environments.

.. autoclass:: testcontainers.core.container.DockerContainer
.. autoclass:: testcontainers.core.image.DockerImage
7 changes: 4 additions & 3 deletions core/testcontainers/core/container.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import contextlib
from platform import system
from typing import Optional
from typing import Optional, Union

from docker.models.containers import Container

from testcontainers.core.docker_client import DockerClient
from testcontainers.core.exceptions import ContainerStartException
from testcontainers.core.image import DockerImage
from testcontainers.core.utils import inside_container, is_arm, setup_logger
from testcontainers.core.waiting_utils import wait_container_is_ready

Expand All @@ -25,11 +26,11 @@ class DockerContainer:
... delay = wait_for_logs(container, "Hello from Docker!")
"""

def __init__(self, image: str, docker_client_kw: Optional[dict] = None, **kwargs) -> None:
def __init__(self, image: Union[DockerImage, str], docker_client_kw: Optional[dict] = None, **kwargs) -> None:
self.env = {}
self.ports = {}
self.volumes = {}
self.image = image
self.image = image.get_wrapped_image() if isinstance(image, DockerImage) else image
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we simplify this logic to be:

  1. if string, construct a DockerImage
  2. get a string which can be passed to docker run
    and try to contain the conditionals inside DockerImage

self._docker = DockerClient(**(docker_client_kw or {}))
self._container = None
self._command = None
Expand Down
4 changes: 3 additions & 1 deletion core/testcontainers/core/docker_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
from docker.errors import NotFound
from docker.models.containers import Container, ContainerCollection

from testcontainers.core.image import DockerImage

from .utils import default_gateway_ip, inside_container, setup_logger

LOGGER = setup_logger(__name__)
Expand All @@ -46,7 +48,7 @@ def __init__(self, **kwargs) -> None:
@ft.wraps(ContainerCollection.run)
def run(
self,
image: str,
image: Union[DockerImage, str],
command: Optional[Union[str, list[str]]] = None,
environment: Optional[dict] = None,
ports: Optional[dict] = None,
Expand Down
115 changes: 115 additions & 0 deletions core/testcontainers/core/image.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import functools as ft
from typing import Optional

import docker
from docker.client import DockerClient
from docker.models.images import Image, ImageCollection

from .utils import setup_logger

LOGGER = setup_logger(__name__)


class DockerImage:
"""
Basic class to manage docker images.

.. doctest::
>>> from testcontainers.core.image import DockerImage

>>> image = DockerImage().from_dockerfile(path="core/tests/", tag="testcontainers/test-image")
>>> image.exists("testcontainers/test-image")
True
>>> image.get("testcontainers/test-image").id
'sha256:...'
>>> image.remove(force=True)
>>> image.exists("testcontainers/test-image")
False
"""

def __init__(self, docker_client_kw: Optional[dict] = None, **kwargs) -> None:
self._docker = DockerClient().from_env(**(docker_client_kw or {}))

def from_dockerfile(self, path: str, tag: str = "local/image") -> "DockerImage":
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets not make it dependent on an image, it should be possible to just use the image id, i think, both based on the java implementation link and also based on experience running docker build and forgetting to specify -t myimage - you can grab the id and tag with a separate command. not sure how feasible this is but without this, seems like it would be quite a limitation on concurrency...

"""
Build an image from a Dockerfile.

Args:
path (str): Path to the Dockerfile
tag (str): Tag for the image

Returns:
DockerImage: The current instance
"""
self.build(path=path, tag=tag)
return self

def from_image(self, repository: str, tag: str = "latest") -> "DockerImage":
"""
Pull an image from the registry.

Args:
repository (str): Image repository
tag (str): Image tag

Returns:
DockerImage: The current instance
"""
self.pull(repository=repository, tag=tag)
return self
Comment on lines +47 to +59
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it probably doesn't need to pull every time - can we make that configurable?


@ft.wraps(ImageCollection.build)
def build(self, **kwargs) -> "DockerImage":
LOGGER.info("Building image from Dockerfile")
self._image, _ = self._docker.images.build(**kwargs)
return self
Comment on lines +61 to +65
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should probably be a private method to abstract away the detail that sometimes it is a dockerfile and other times it is a docker image that the container runtime would pull.


@ft.wraps(ImageCollection.pull)
def pull(self, **kwargs) -> Image:
LOGGER.info("Pulling image")
self._image = self._docker.images.pull(**kwargs)
return self

@ft.wraps(ImageCollection.get)
def get(self, image: str) -> Image:
LOGGER.info(f"Getting image {image}")
image_obj = self._docker.images.get(image)
return image_obj

@ft.wraps(ImageCollection.remove)
def remove(self, **kwargs) -> None:
LOGGER.info(f"Removing image {self._image}")
self._image.remove(**kwargs)
Comment on lines +73 to +82
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this seems like a lot of stuff that is neat but may be painful to maintain - if this were java i would say lets annotate it with "incubating" or some other variant of message that this is a use at your own risk API. can we add a doc string comment here instead saying that this is an "incubating" API? i think this makes maintenance of this code much less intimidating

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same for exists... if its used anywhere, make it private, if not used - should come with big hazard warning that says it may go away


@property
def id(self) -> str:
return self._image.id

@property
def short_id(self) -> str:
return self._image.short_id

@property
def tags(self) -> dict:
return self._image.tags

def get_wrapped_image(self) -> Image:
return self._image

def exists(self, image: str) -> bool:
"""
Check if the image exists in the local registry.

Args:
image (str): Image name

Returns:
bool: True if the image exists, False otherwise
Raises:
docker.errors.ImageNotFound: If the image does not exist
"""
try:
self.get(image)
return True
except docker.errors.ImageNotFound:
return False
11 changes: 10 additions & 1 deletion core/tests/test_core.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import pytest

from testcontainers.core.container import DockerContainer
from testcontainers.core.image import DockerImage
from testcontainers.core.waiting_utils import wait_for_logs


Expand Down Expand Up @@ -29,3 +29,12 @@ def test_can_get_logs():
wait_for_logs(container, "Hello from Docker!")
stdout, stderr = container.get_logs()
assert stdout, "There should be something on stdout"


def test_create_container_from_image():
image = DockerImage().from_dockerfile(path="core/tests/", tag="testcontainers/test-image")

container = DockerContainer(image)
container.start()
container.stop(force=True, delete_volume=True)
image.remove(force=True)
34 changes: 34 additions & 0 deletions core/tests/test_image.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import pytest
from testcontainers.core.image import DockerImage


def test_docker_image_from_dockerfile():
image = DockerImage().from_dockerfile(path="core/tests/", tag="testcontainers/test-image")

assert image.exists("testcontainers/test-image") == True

retrieved_image = image.get("testcontainers/test-image")

assert retrieved_image.id == image.id
assert retrieved_image.short_id == image.short_id
assert retrieved_image.tags == image.tags

image.remove(force=True)

assert image.exists("testcontainers/test-image") == False


def test_docker_image_from_image():
image = DockerImage().from_image(repository="alpine")

assert image.exists("alpine") == True

retrieved_image = image.get("alpine")

assert retrieved_image.id == image.id
assert retrieved_image.short_id == image.short_id
assert retrieved_image.tags == image.tags

image.remove(force=True)

assert image.exists("alpine") == False