diff --git a/.devcontainer/commands/post-create-command.sh b/.devcontainer/commands/post-create-command.sh index c3229490f..aee5cecf2 100755 --- a/.devcontainer/commands/post-create-command.sh +++ b/.devcontainer/commands/post-create-command.sh @@ -2,4 +2,5 @@ echo "Running post-create-command.sh" curl -sSL https://install.python-poetry.org | python3 - +poetry lock --no-update poetry install --all-extras diff --git a/INDEX.rst b/INDEX.rst index be5e3d1cd..dfaa34b8f 100644 --- a/INDEX.rst +++ b/INDEX.rst @@ -27,6 +27,7 @@ testcontainers-python facilitates the use of Docker containers for functional an modules/mongodb/README modules/mssql/README modules/mysql/README + modules/nats/README modules/neo4j/README modules/nginx/README modules/opensearch/README diff --git a/conf.py b/conf.py index 8ac304a25..5887d3a70 100644 --- a/conf.py +++ b/conf.py @@ -52,7 +52,7 @@ # General information about the project. project = "testcontainers" -copyright = "2017, Sergey Pirogov" # noqa: A001 +copyright = "2017-2024, Sergey Pirogov and Testcontainers Python contributors" # noqa: A001 author = "Sergey Pirogov" # The version info for the project you're documenting, acts as replacement for diff --git a/core/testcontainers/core/container.py b/core/testcontainers/core/container.py index 6ecc384bd..b21feabc2 100644 --- a/core/testcontainers/core/container.py +++ b/core/testcontainers/core/container.py @@ -76,6 +76,7 @@ def start(self) -> "DockerContainer": def stop(self, force=True, delete_volume=True) -> None: self._container.remove(force=force, v=delete_volume) + self.get_docker_client().client.close() def __enter__(self) -> "DockerContainer": return self.start() diff --git a/core/testcontainers/core/generic.py b/core/testcontainers/core/generic.py index 21bf9d7e4..a3bff96e2 100644 --- a/core/testcontainers/core/generic.py +++ b/core/testcontainers/core/generic.py @@ -36,7 +36,10 @@ def _connect(self) -> None: import sqlalchemy engine = sqlalchemy.create_engine(self.get_connection_url()) - engine.connect() + try: + engine.connect() + finally: + engine.dispose() def get_connection_url(self) -> str: raise NotImplementedError diff --git a/modules/clickhouse/testcontainers/clickhouse/__init__.py b/modules/clickhouse/testcontainers/clickhouse/__init__.py index 147940199..fbc8fab65 100644 --- a/modules/clickhouse/testcontainers/clickhouse/__init__.py +++ b/modules/clickhouse/testcontainers/clickhouse/__init__.py @@ -12,9 +12,8 @@ # under the License. import os from typing import Optional - -import clickhouse_driver -from clickhouse_driver.errors import Error +from urllib.error import HTTPError, URLError +from urllib.request import urlopen from testcontainers.core.generic import DbContainer from testcontainers.core.utils import raise_for_deprecated_parameter @@ -48,7 +47,7 @@ def __init__( username: Optional[str] = None, password: Optional[str] = None, dbname: Optional[str] = None, - **kwargs + **kwargs, ) -> None: raise_for_deprecated_parameter(kwargs, "user", "username") super().__init__(image=image, **kwargs) @@ -57,11 +56,14 @@ def __init__( self.dbname = dbname or os.environ.get("CLICKHOUSE_DB", "test") self.port = port self.with_exposed_ports(self.port) + self.with_exposed_ports(8123) - @wait_container_is_ready(Error, EOFError) + @wait_container_is_ready(HTTPError, URLError) def _connect(self) -> None: - with clickhouse_driver.Client.from_url(self.get_connection_url()) as client: - client.execute("SELECT version()") + # noinspection HttpUrlsUsage + url = f"http://{self.get_container_host_ip()}:{self.get_exposed_port(8123)}" + with urlopen(url) as r: + assert b"Ok" in r.read() def _configure(self) -> None: self.with_env("CLICKHOUSE_USER", self.username) diff --git a/modules/elasticsearch/tests/test_elasticsearch.py b/modules/elasticsearch/tests/test_elasticsearch.py index e174ec47b..661a550c6 100644 --- a/modules/elasticsearch/tests/test_elasticsearch.py +++ b/modules/elasticsearch/tests/test_elasticsearch.py @@ -6,8 +6,8 @@ from testcontainers.elasticsearch import ElasticSearchContainer -# The versions below were the current supported versions at time of writing (2022-08-11) -@pytest.mark.parametrize("version", ["6.8.23", "7.17.5", "8.3.3"]) +# The versions below should reflect the latest stable releases +@pytest.mark.parametrize("version", ["7.17.18", "8.12.2"]) def test_docker_run_elasticsearch(version): with ElasticSearchContainer(f"elasticsearch:{version}", mem_limit="3G") as es: resp = urllib.request.urlopen(es.get_url()) diff --git a/modules/mongodb/testcontainers/mongodb/__init__.py b/modules/mongodb/testcontainers/mongodb/__init__.py index 1ff029258..eee623c6d 100644 --- a/modules/mongodb/testcontainers/mongodb/__init__.py +++ b/modules/mongodb/testcontainers/mongodb/__init__.py @@ -17,7 +17,7 @@ from testcontainers.core.generic import DbContainer from testcontainers.core.utils import raise_for_deprecated_parameter -from testcontainers.core.waiting_utils import wait_container_is_ready +from testcontainers.core.waiting_utils import wait_for_logs class MongoDbContainer(DbContainer): @@ -81,9 +81,8 @@ def get_connection_url(self) -> str: port=self.port, ) - @wait_container_is_ready() - def _connect(self) -> MongoClient: - return MongoClient(self.get_connection_url()) + def _connect(self) -> None: + wait_for_logs(self, "Waiting for connections") def get_connection_client(self) -> MongoClient: - return self._connect() + return MongoClient(self.get_connection_url()) diff --git a/modules/nats/README.rst b/modules/nats/README.rst new file mode 100644 index 000000000..a38a21466 --- /dev/null +++ b/modules/nats/README.rst @@ -0,0 +1 @@ +.. autoclass:: testcontainers.nats.NatsContainer diff --git a/modules/nats/testcontainers/nats/__init__.py b/modules/nats/testcontainers/nats/__init__.py new file mode 100644 index 000000000..a49c40d80 --- /dev/null +++ b/modules/nats/testcontainers/nats/__init__.py @@ -0,0 +1,65 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# 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 testcontainers.core.container import DockerContainer +from testcontainers.core.waiting_utils import wait_container_is_ready, wait_for_logs + + +class NatsContainer(DockerContainer): + """ + Nats container. + + Example: + + .. doctest:: + + >>> from testcontainers.nats import NatsContainer + + >>> with NatsContainer() as nats_container: + ... nc = nats_container.get_client() + """ + + def __init__( + self, + image: str = "nats:latest", + client_port: int = 4222, + management_port: int = 8222, + expected_ready_log: str = "Server is ready", + ready_timeout_secs: int = 120, + **kwargs, + ) -> None: + super().__init__(image, **kwargs) + self.client_port = client_port + self.management_port = management_port + self._expected_ready_log = expected_ready_log + self._ready_timeout_secs = max(ready_timeout_secs, 0) + self.with_exposed_ports(self.client_port, self.management_port) + + @wait_container_is_ready() + def _healthcheck(self) -> None: + wait_for_logs(self, self._expected_ready_log, timeout=self._ready_timeout_secs) + + def nats_uri(self) -> str: + return f"nats://{self.get_container_host_ip()}:{self.get_exposed_port(self.client_port)}" + + def nats_host_and_port(self) -> tuple[str, int]: + return self.get_container_host_ip(), self.get_exposed_port(self.client_port) + + def nats_management_uri(self) -> str: + return f"nats://{self.get_container_host_ip()}:{self.get_exposed_port(self.management_port)}" + + def start(self) -> "NatsContainer": + super().start() + self._healthcheck() + return self diff --git a/modules/nats/tests/test_nats.py b/modules/nats/tests/test_nats.py new file mode 100644 index 000000000..4815d59e9 --- /dev/null +++ b/modules/nats/tests/test_nats.py @@ -0,0 +1,101 @@ +from testcontainers.nats import NatsContainer +from uuid import uuid4 +import pytest + + +""" +If you are developing this and you want to test more advanced scenarios using a client +Activate your poetry shell. +pip install nats-py +This will get nats-py into your environment but keep it out of the project + + +""" + + +NO_NATS_CLIENT = True +try: + from nats import connect as nats_connect + from nats.aio.client import Client as NATSClient + + NO_NATS_CLIENT = False +except ImportError: + pass + + +async def get_client(container: NatsContainer) -> "NATSClient": + """ + Get a nats client. + + Returns: + client: Nats client to connect to the container. + """ + conn_string = container.nats_uri() + client = await nats_connect(conn_string) + return client + + +def test_basic_container_ops(): + with NatsContainer() as container: + # Not sure how to get type information without doing this + container: NatsContainer = container + h, p = container.nats_host_and_port() + assert h == "localhost" + uri = container.nats_uri() + management_uri = container.nats_management_uri() + + assert uri != management_uri + + +pytest.mark.usefixtures("anyio_backend") + + +@pytest.mark.skipif(NO_NATS_CLIENT, reason="No NATS Client Available") +@pytest.mark.parametrize("anyio_backend", ["asyncio"]) +async def test_pubsub(anyio_backend): + with NatsContainer() as container: + nc: NATSClient = await get_client(container) + + topic = str(uuid4()) + + sub = await nc.subscribe(topic) + sent_message = b"Test-Containers" + await nc.publish(topic, b"Test-Containers") + received_msg = await sub.next_msg() + print("Received:", received_msg) + assert sent_message == received_msg.data + await nc.flush() + await nc.close() + + +pytest.mark.usefixtures("anyio_backend") + + +@pytest.mark.parametrize("anyio_backend", ["asyncio"]) +@pytest.mark.skipif(NO_NATS_CLIENT, reason="No NATS Client Available") +async def test_more_complex_example(anyio_backend): + with NatsContainer() as container: + nc: NATSClient = await get_client(container) + + await nc.publish("greet.joe", b"hello") + + sub = await nc.subscribe("greet.*") + + try: + await sub.next_msg(timeout=0.1) + except TimeoutError: + pass + + await nc.publish("greet.joe", b"hello.joe") + await nc.publish("greet.pam", b"hello.pam") + + first = await sub.next_msg(timeout=0.1) + assert b"hello.joe" == first.data + + second = await sub.next_msg(timeout=0.1) + assert b"hello.pam" == second.data + + await nc.publish("greet.bob", b"hello") + + await sub.unsubscribe() + await nc.drain() diff --git a/poetry.lock b/poetry.lock index f97690266..49b11556c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1491,6 +1491,21 @@ files = [ {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, ] +[[package]] +name = "nats-py" +version = "2.7.2" +description = "NATS client for Python" +optional = true +python-versions = ">=3.7" +files = [ + {file = "nats-py-2.7.2.tar.gz", hash = "sha256:0c97b4a57bed0ef1ff9ae6c19bc115ec7ca8ede5ab3e001fd00a377056a547cf"}, +] + +[package.extras] +aiohttp = ["aiohttp"] +fast-parse = ["fast-mail-parser"] +nkeys = ["nkeys"] + [[package]] name = "neo4j" version = "5.16.0" @@ -2165,6 +2180,24 @@ tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +[[package]] +name = "pytest-asyncio" +version = "0.23.5" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-asyncio-0.23.5.tar.gz", hash = "sha256:3a048872a9c4ba14c3e90cc1aa20cbc2def7d01c7c8db3777ec281ba9c057675"}, + {file = "pytest_asyncio-0.23.5-py3-none-any.whl", hash = "sha256:4e7093259ba018d58ede7d5315131d21923a60f8a6e9ee266ce1589685c89eac"}, +] + +[package.dependencies] +pytest = ">=7.0.0,<9" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + [[package]] name = "pytest-cov" version = "4.1.0" @@ -3147,6 +3180,7 @@ minio = ["minio"] mongodb = ["pymongo"] mssql = ["pymssql", "sqlalchemy"] mysql = ["pymysql", "sqlalchemy"] +nats = [] neo4j = ["neo4j"] nginx = [] opensearch = ["opensearch-py"] @@ -3159,4 +3193,4 @@ selenium = ["selenium"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<4.0" -content-hash = "f4cb027301e265217ccb581b0ddd06fe6d91319fbcfbc3d20504a1fdbc45d7b1" +content-hash = "9228589aa47564c2a463e34ed57463652eac3d45d47116ee2a749d64758bd85c" diff --git a/pyproject.toml b/pyproject.toml index 7afb4cd96..e8dfec88d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ packages = [ { include = "testcontainers", from = "modules/mongodb" }, { include = "testcontainers", from = "modules/mssql" }, { include = "testcontainers", from = "modules/mysql" }, + { include = "testcontainers", from = "modules/nats" }, { include = "testcontainers", from = "modules/neo4j" }, { include = "testcontainers", from = "modules/nginx" }, { include = "testcontainers", from = "modules/opensearch" }, @@ -83,6 +84,7 @@ psycopg2-binary = { version = "*", optional = true } pika = { version = "*", optional = true } redis = { version = "*", optional = true } selenium = { version = "*", optional = true } +nats-py = { version = "*", optional = true } [tool.poetry.extras] arangodb = ["python-arango"] @@ -98,6 +100,7 @@ minio = ["minio"] mongodb = ["pymongo"] mssql = ["sqlalchemy", "pymssql"] mysql = ["sqlalchemy", "pymysql"] +nats = [] neo4j = ["neo4j"] nginx = [] opensearch = ["opensearch-py"] @@ -116,6 +119,7 @@ pytest-cov = "4.1.0" sphinx = "^7.2.6" twine = "^4.0.2" anyio = "^4.3.0" +pytest-asyncio = "^0.23.5" [[tool.poetry.source]] name = "PyPI" @@ -222,6 +226,7 @@ mypy_path = [ # "modules/mongodb", # "modules/mssql", # "modules/mysql", +# "modules/nats", # "modules/neo4j", # "modules/nginx", # "modules/opensearch",