Skip to content

Commit

Permalink
feat(core): Added ServerContainer (#595)
Browse files Browse the repository at this point in the history
As part of the effort described, detailed and presented on #559 
This is the seconds PR (out of 4) that should provide all the groundwork
to support containers running a server.


This would allow users to use custom images:
```python
with DockerImage(path=".", tag="test:latest") as image:
    with ServerContainer(port=9000, image=image) as srv:
        # Test something with/on the server using port 9000
```

Next in line are:
`feat(core): Added FastAPI module`
`feat(core): Added AWS Lambda module`

---
Based on the work done on #585
Expended from issue #83

---------

Co-authored-by: David Ankin <daveankin@gmail.com>
  • Loading branch information
Tranquility2 and alexanderankin authored Jun 18, 2024
1 parent 59cb6fc commit 0768490
Show file tree
Hide file tree
Showing 11 changed files with 178 additions and 56 deletions.
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ ${TESTS_DIND} : %/tests-dind : image
docs :
poetry run sphinx-build -nW . docs/_build

# Target to build docs watching for changes as per https://stackoverflow.com/a/21389615
docs-watch :
poetry run sphinx-autobuild . docs/_build # requires 'pip install sphinx-autobuild'

doctests : ${DOCTESTS}
poetry run sphinx-build -b doctest . docs/_build

Expand Down
16 changes: 13 additions & 3 deletions core/README.rst
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
testcontainers-core
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

Using `DockerContainer` and `DockerImage` directly:

.. doctest::
Expand All @@ -18,3 +16,15 @@ Using `DockerContainer` and `DockerImage` directly:
>>> with DockerImage(path="./core/tests/image_fixtures/sample/", tag="test-sample:latest") as image:
... 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
5 changes: 3 additions & 2 deletions core/testcontainers/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,10 @@ class TestcontainersConfiguration:

@property
def docker_auth_config(self):
if "DOCKER_AUTH_CONFIG" in _WARNINGS:
config = self._docker_auth_config
if config and "DOCKER_AUTH_CONFIG" in _WARNINGS:
warning(_WARNINGS.pop("DOCKER_AUTH_CONFIG"))
return self._docker_auth_config
return config

@docker_auth_config.setter
def docker_auth_config(self, value: str):
Expand Down
73 changes: 72 additions & 1 deletion core/testcontainers/core/generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,14 @@
# 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
from typing import Optional, Union
from urllib.error import HTTPError
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 All @@ -29,6 +32,8 @@

class DbContainer(DockerContainer):
"""
**DEPRECATED (for removal)**
Generic database container.
"""

Expand Down Expand Up @@ -79,3 +84,69 @@ 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)
10 changes: 6 additions & 4 deletions core/testcontainers/core/image.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from typing import TYPE_CHECKING, Optional
from os import PathLike
from typing import TYPE_CHECKING, Optional, Union

from typing_extensions import Self

Expand Down Expand Up @@ -28,23 +29,24 @@ class DockerImage:

def __init__(
self,
path: str,
path: Union[str, PathLike],
docker_client_kw: Optional[dict] = None,
tag: Optional[str] = None,
clean_up: bool = True,
**kwargs,
) -> None:
self.tag = tag
self.path = path
self.id = None
self._docker = DockerClient(**(docker_client_kw or {}))
self.clean_up = clean_up
self._kwargs = kwargs
self._image = None
self._logs = None

def build(self, **kwargs) -> Self:
logger.info(f"Building image from {self.path}")
docker_client = self.get_docker_client()
self._image, self._logs = docker_client.build(path=self.path, tag=self.tag, **kwargs)
self._image, self._logs = docker_client.build(path=str(self.path), tag=self.tag, **kwargs)
logger.info(f"Built image {self.short_id} with tag {self.tag}")
return self

Expand Down
3 changes: 3 additions & 0 deletions core/tests/image_fixtures/python_server/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
FROM python:3-alpine
EXPOSE 9000
CMD ["python", "-m", "http.server", "9000"]
47 changes: 47 additions & 0 deletions core/tests/test_generics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import re
from pathlib import Path
from typing import Optional

import pytest
from httpx import get

from testcontainers.core.waiting_utils import wait_for_logs
from testcontainers.core.image import DockerImage
from testcontainers.core.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):
with (
DockerImage(
path=TEST_DIR / "image_fixtures/python_server",
tag=test_image_tag,
clean_up=test_image_cleanup,
#
) as docker_image,
ServerContainer(port=port, image=docker_image) as srv,
):
image_short_id = docker_image.short_id
image_build_logs = docker_image.get_logs()
# check if dict is in any of the logs
assert {"stream": f"Step 2/3 : EXPOSE {port}"} in image_build_logs, "Image logs mismatch"
assert (port, None) in srv.ports.items(), "Port mismatch"
with pytest.raises(NotImplementedError):
srv.get_api_url()
test_url = srv._create_connection_url()
assert re.match(r"http://localhost:\d+", test_url), "Connection URL mismatch"

check_for_image(image_short_id, test_image_cleanup)


def test_like_doctest():
with DockerImage(path=TEST_DIR / "image_fixtures/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)
assert response.status_code == 200, "Response status code is not 200"
delay = wait_for_logs(srv, "GET / HTTP/1.1")
print(delay)
38 changes: 5 additions & 33 deletions index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,40 +13,10 @@ testcontainers-python
testcontainers-python facilitates the use of Docker containers for functional and integration testing. The collection of packages currently supports the following features.

.. toctree::
:maxdepth: 1

core/README
modules/arangodb/README
modules/azurite/README
modules/cassandra/README
modules/chroma/README
modules/clickhouse/README
modules/elasticsearch/README
modules/google/README
modules/influxdb/README
modules/k3s/README
modules/kafka/README
modules/keycloak/README
modules/localstack/README
modules/memcached/README
modules/milvus/README
modules/minio/README
modules/mongodb/README
modules/mqtt/README
modules/mssql/README
modules/mysql/README
modules/nats/README
modules/neo4j/README
modules/nginx/README
modules/opensearch/README
modules/oracle-free/README
modules/postgres/README
modules/qdrant/README
modules/rabbitmq/README
modules/redis/README
modules/registry/README
modules/selenium/README
modules/vault/README
modules/weaviate/README
modules/index

Getting Started
---------------
Expand Down Expand Up @@ -190,4 +160,6 @@ Testcontainers is a collection of `implicit namespace packages <https://peps.pyt
Contributing a New Feature
^^^^^^^^^^^^^^^^^^^^^^^^^^

You want to contribute a new feature or container? Great! You can do that in six steps as outlined `here <https://github.com/testcontainers/testcontainers-python/blob/main/.github/PULL_REQUEST_TEMPLATE/new_container.md>__`.
You want to contribute a new feature or container?
Great! You can do that in six steps as outlined
`here <https://github.com/testcontainers/testcontainers-python/blob/main/.github/PULL_REQUEST_TEMPLATE/new_container.md>`_.
11 changes: 11 additions & 0 deletions modules/index.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
Community Modules
=================

..
glob:
https://stackoverflow.com/a/44572883/4971476
.. toctree::
:glob:

*/README
8 changes: 4 additions & 4 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 10 additions & 9 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -142,19 +142,20 @@ mypy = "1.7.1"
pre-commit = "^3.6"
pytest = "7.4.3"
pytest-cov = "4.1.0"
sphinx = "^7.2.6"
twine = "^4.0.2"
anyio = "^4.3.0"
sphinx = "7.2.6"
twine = "4.0.2"
anyio = "4.3.0"
# for tests only
psycopg2-binary = "*"
pg8000 = "*"
sqlalchemy = "*"
psycopg = "*"
cassandra-driver = "*"
psycopg2-binary = "2.9.9"
pg8000 = "1.30.5"
sqlalchemy = "2.0.28"
psycopg = "3.1.18"
cassandra-driver = "3.29.1"
pytest-asyncio = "0.23.5"
kafka-python-ng = "^2.2.0"
hvac = "*"
hvac = "2.1.0"
pymilvus = "2.4.3"
httpx = "0.27.0"
paho-mqtt = "2.1.0"

[[tool.poetry.source]]
Expand Down

0 comments on commit 0768490

Please sign in to comment.