diff --git a/Makefile b/Makefile index b7bf2826b..4a0594095 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/core/README.rst b/core/README.rst index bdc46db6d..8479efac8 100644 --- a/core/README.rst +++ b/core/README.rst @@ -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:: @@ -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 diff --git a/core/testcontainers/core/config.py b/core/testcontainers/core/config.py index 34b8177a2..3522b91f0 100644 --- a/core/testcontainers/core/config.py +++ b/core/testcontainers/core/config.py @@ -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): diff --git a/core/testcontainers/core/generic.py b/core/testcontainers/core/generic.py index 6dd635e69..11456a515 100644 --- a/core/testcontainers/core/generic.py +++ b/core/testcontainers/core/generic.py @@ -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 @@ -29,6 +32,8 @@ class DbContainer(DockerContainer): """ + **DEPRECATED (for removal)** + Generic database container. """ @@ -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) diff --git a/core/testcontainers/core/image.py b/core/testcontainers/core/image.py index 399200bf8..4004e9e44 100644 --- a/core/testcontainers/core/image.py +++ b/core/testcontainers/core/image.py @@ -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 @@ -28,7 +29,7 @@ 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, @@ -36,15 +37,16 @@ def __init__( ) -> 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 diff --git a/core/tests/image_fixtures/python_server/Dockerfile b/core/tests/image_fixtures/python_server/Dockerfile new file mode 100644 index 000000000..844acf2b3 --- /dev/null +++ b/core/tests/image_fixtures/python_server/Dockerfile @@ -0,0 +1,3 @@ +FROM python:3-alpine +EXPOSE 9000 +CMD ["python", "-m", "http.server", "9000"] diff --git a/core/tests/test_generics.py b/core/tests/test_generics.py new file mode 100644 index 000000000..340ac6655 --- /dev/null +++ b/core/tests/test_generics.py @@ -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) diff --git a/index.rst b/index.rst index 7bf056799..6e7ed596c 100644 --- a/index.rst +++ b/index.rst @@ -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 --------------- @@ -190,4 +160,6 @@ Testcontainers is a collection of `implicit namespace packages __`. +You want to contribute a new feature or container? +Great! You can do that in six steps as outlined +`here `_. diff --git a/modules/index.rst b/modules/index.rst new file mode 100644 index 000000000..d2a67a3d4 --- /dev/null +++ b/modules/index.rst @@ -0,0 +1,11 @@ +Community Modules +================= + +.. + glob: + https://stackoverflow.com/a/44572883/4971476 + +.. toctree:: + :glob: + + */README diff --git a/poetry.lock b/poetry.lock index 8d7db3214..891c7bd75 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1391,7 +1391,7 @@ setuptools = "*" name = "h11" version = "0.14.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -optional = true +optional = false python-versions = ">=3.7" files = [ {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, @@ -1428,7 +1428,7 @@ files = [ name = "httpcore" version = "1.0.5" description = "A minimal low-level HTTP client." -optional = true +optional = false python-versions = ">=3.8" files = [ {file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"}, @@ -1449,7 +1449,7 @@ trio = ["trio (>=0.22.0,<0.26.0)"] name = "httpx" version = "0.27.0" description = "The next generation HTTP client." -optional = true +optional = false python-versions = ">=3.8" files = [ {file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"}, @@ -4479,4 +4479,4 @@ weaviate = ["weaviate-client"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<4.0" -content-hash = "f91d5824e3c430ff0ff0256fe7b32b8c57fe2437a5dbd098f75a5d4e960f3209" +content-hash = "043c7eea4ca72646a19a705891b26577a27149673ba38c8a6dd4732d30ce081c" diff --git a/pyproject.toml b/pyproject.toml index fe0db3320..afe841c82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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]]