diff --git a/docs/community/cratedb.rst b/docs/community/cratedb.rst new file mode 100644 index 000000000..a6603ca8e --- /dev/null +++ b/docs/community/cratedb.rst @@ -0,0 +1,2 @@ +.. autoclass:: testcontainers.community.cratedb.CrateDBContainer +.. title:: testcontainers.community.cratedb.CrateDBContainer diff --git a/docs/modules/cratedb.md b/docs/modules/cratedb.md new file mode 100644 index 000000000..5d660ee5c --- /dev/null +++ b/docs/modules/cratedb.md @@ -0,0 +1,34 @@ +# CrateDB + +## Introduction + +The Testcontainers module for [CrateDB](https://cratedb.com), a distributed SQL +database for real-time analytics. CrateDB is PostgreSQL wire-compatible and +provides a SQLAlchemy dialect (`crate://`) that talks to its HTTP interface. + +## Adding this module to your project dependencies + +Please run the following command to add the CrateDB module to your python dependencies: + +```bash +pip install testcontainers[cratedb] sqlalchemy sqlalchemy-cratedb +``` + +## Usage example + + + +[Creating a CrateDB container](cratedb_example.py) + + + +## Configuration + +The CrateDB container can be configured with the following parameters: + +- `image`: Docker image to use (default: `"crate/crate:latest"`) +- `port`: container port used to build the connection URL (default: `4200`, the HTTP interface) +- `username`: Database username (default: `"crate"`, or the `CRATEDB_USER` env var) +- `password`: Database password (default: `"crate"`, or the `CRATEDB_PASSWORD` env var) +- `dialect`: SQLAlchemy dialect used in the connection URL (default: `"crate"`) +- `cmd_opts`: extra `-C=` options passed to CrateDB (merged over the single-node defaults) diff --git a/docs/modules/cratedb_example.py b/docs/modules/cratedb_example.py new file mode 100644 index 000000000..7d12d2b61 --- /dev/null +++ b/docs/modules/cratedb_example.py @@ -0,0 +1,43 @@ +import sqlalchemy +from sqlalchemy import text + +from testcontainers.community.cratedb import CrateDBContainer + + +def basic_example(): + with CrateDBContainer("crate:latest") as cratedb: + # CrateDB speaks the SQLAlchemy `crate://` dialect over its HTTP interface. + engine = sqlalchemy.create_engine(cratedb.get_connection_url()) + + with engine.begin() as conn: + conn.execute( + text(""" + CREATE TABLE IF NOT EXISTS summits ( + name TEXT PRIMARY KEY, + height INT + ) + """) + ) + print("Created table") + + conn.execute( + text("INSERT INTO summits (name, height) VALUES (:name, :height)"), + [ + {"name": "Mont Blanc", "height": 4808}, + {"name": "Monte Rosa", "height": 4634}, + {"name": "Dom", "height": 4545}, + ], + ) + # CrateDB is eventually consistent for reads; refresh to read-your-writes. + conn.execute(text("REFRESH TABLE summits")) + print("Inserted data") + + with engine.connect() as conn: + result = conn.execute(text("SELECT name, height FROM summits ORDER BY height DESC")) + print("\nQuery results:") + for row in result: + print(f"Name: {row.name}, Height: {row.height}") + + +if __name__ == "__main__": + basic_example() diff --git a/mkdocs.yml b/mkdocs.yml index 0a31629a2..d918b4238 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -58,6 +58,7 @@ nav: - modules/clickhouse.md - modules/cockroachdb.md - modules/cosmosdb.md + - modules/cratedb.md - modules/db2.md - modules/elasticsearch.md - modules/influxdb.md diff --git a/pyproject.toml b/pyproject.toml index 3b3adbada..1ded7d24e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ cassandra = [] clickhouse = ["clickhouse-driver"] cosmosdb = ["azure-cosmos>=4"] cockroachdb = [] +cratedb = ["sqlalchemy-cratedb"] db2 = [ "sqlalchemy>=2", "ibm_db_sa; platform_machine != 'aarch64' and platform_machine != 'arm64'", @@ -128,6 +129,7 @@ test = [ "pymilvus>=2", "paho-mqtt>=2", "sqlalchemy-cockroachdb>=2", + "sqlalchemy-cratedb", "paramiko>=4", "twine>=6.2.0", "anyio>=4", diff --git a/src/testcontainers/community/cratedb/__init__.py b/src/testcontainers/community/cratedb/__init__.py new file mode 100644 index 000000000..88cdceaee --- /dev/null +++ b/src/testcontainers/community/cratedb/__init__.py @@ -0,0 +1,111 @@ +# +# 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. +import os +from typing import ClassVar, Optional + +from testcontainers.community.generic.sql import SqlContainer +from testcontainers.core.utils import raise_for_deprecated_parameter +from testcontainers.core.wait_strategies import HttpWaitStrategy + +# CrateDB's HTTP interface. The SQLAlchemy `crate://` dialect talks to CrateDB +# over HTTP, so this is the port used for connection URLs and readiness checks. +HTTP_PORT = 4200 +# CrateDB's PostgreSQL wire-protocol interface, exposed for convenience. +PSQL_PORT = 5432 + + +class CrateDBContainer(SqlContainer): + """ + CrateDB database container. + + Example: + + The example spins up a CrateDB database and connects to it using + SQLAlchemy and the ``sqlalchemy-cratedb`` dialect, which talks to + CrateDB over its HTTP interface (port 4200). + + .. doctest:: + + >>> from testcontainers.community.cratedb import CrateDBContainer + >>> import sqlalchemy + + >>> with CrateDBContainer("crate:5.10") as cratedb: + ... engine = sqlalchemy.create_engine(cratedb.get_connection_url()) + ... with engine.begin() as connection: + ... result = connection.execute(sqlalchemy.text("select name from sys.cluster")) + ... cluster_name, = result.fetchone() + """ + + # Default command-line options. CrateDB needs single-node discovery to run + # as a one-node cluster suitable for testing. + CMD_OPTS: ClassVar[dict[str, str]] = {"discovery.type": "single-node"} + + def __init__( + self, + image: str = "crate/crate:latest", + port: int = HTTP_PORT, + username: Optional[str] = None, + password: Optional[str] = None, + dialect: str = "crate", + cmd_opts: Optional[dict[str, str]] = None, + **kwargs, + ) -> None: + """ + :param image: Docker image name (with optional tag). + :param port: container port used to build the connection URL; defaults + to the HTTP port (4200) used by the ``crate://`` dialect. + :param username: username for the DB; falls back to the ``CRATEDB_USER`` + environment variable, then ``crate``. + :param password: password for the DB; falls back to the + ``CRATEDB_PASSWORD`` environment variable, then ``crate``. + :param dialect: SQLAlchemy dialect used in the connection URL. + :param cmd_opts: extra ``-C=`` options passed to CrateDB, + merged over (and able to override) the defaults. + """ + raise_for_deprecated_parameter(kwargs, "user", "username") + # Readiness is signalled by CrateDB's HTTP interface returning 200; this + # keeps startup free of any database client library. + super().__init__(image, wait_strategy=HttpWaitStrategy(HTTP_PORT).for_status_code(200), **kwargs) + + cmd_opts = cmd_opts or {} + self._command = self._build_cmd({**self.CMD_OPTS, **cmd_opts}) + + self.username = username or os.environ.get("CRATEDB_USER", "crate") + self.password = password or os.environ.get("CRATEDB_PASSWORD", "crate") + self.port = port + self.dialect = dialect + + self.with_exposed_ports(HTTP_PORT, PSQL_PORT) + + @staticmethod + def _build_cmd(opts: dict[str, str]) -> str: + """Render a CrateDB ``-C= ...`` command-line string.""" + cmd = [] + for key, val in opts.items(): + if isinstance(val, bool): + val = str(val).lower() + cmd.append(f"-C{key}={val}") + return " ".join(cmd) + + def _configure(self) -> None: + self.with_env("CRATEDB_USER", self.username) + self.with_env("CRATEDB_PASSWORD", self.password) + + def get_connection_url(self, host: Optional[str] = None) -> str: + return self._create_connection_url( + dialect=self.dialect, + username=self.username, + password=self.password, + host=host, + port=self.port, + ) diff --git a/src/testcontainers/cratedb.py b/src/testcontainers/cratedb.py new file mode 100644 index 000000000..eae33909c --- /dev/null +++ b/src/testcontainers/cratedb.py @@ -0,0 +1,15 @@ +import warnings + +from testcontainers.community.cratedb import ( + CrateDBContainer, +) + +warnings.warn( + "testcontainers.cratedb is deprecated, use testcontainers.community.cratedb instead", + DeprecationWarning, + stacklevel=2, +) + +__all__ = [ + "CrateDBContainer", +] diff --git a/tests/community/cratedb/test_cratedb.py b/tests/community/cratedb/test_cratedb.py new file mode 100644 index 000000000..e9516d271 --- /dev/null +++ b/tests/community/cratedb/test_cratedb.py @@ -0,0 +1,47 @@ +import urllib.parse + +import pytest +import sqlalchemy + +from testcontainers.community.cratedb import CrateDBContainer + + +@pytest.mark.parametrize("version", ["5.10", "latest"]) +def test_docker_run_cratedb(version: str): + with CrateDBContainer(f"crate:{version}") as cratedb: + engine = sqlalchemy.create_engine(cratedb.get_connection_url()) + with engine.begin() as connection: + result = connection.execute(sqlalchemy.text("select 1 + 2 + 3 + 4 + 5")) + assert result.fetchone()[0] == 15 + + +def test_cratedb_connection_url(): + with CrateDBContainer("crate:latest", username="crate", password="crate") as cratedb: + url = urllib.parse.urlparse(cratedb.get_connection_url()) + assert url.scheme == "crate" + credentials, location = url.netloc.split("@") + assert credentials == "crate:crate" + host, port = location.split(":") + assert host == cratedb.get_container_host_ip() + assert int(port) == cratedb.get_exposed_port(cratedb.port) + + +@pytest.mark.parametrize( + "cmd_opts, expected", + [ + pytest.param( + {"indices.breaker.total.limit": "90%"}, + "-Cdiscovery.type=single-node -Cindices.breaker.total.limit=90%", + id="add_cmd_option", + ), + pytest.param( + {"discovery.type": "zen", "indices.breaker.total.limit": "90%"}, + "-Cdiscovery.type=zen -Cindices.breaker.total.limit=90%", + id="override_defaults", + ), + ], +) +def test_build_command(cmd_opts, expected): + # Pure unit test: the command line is assembled in __init__, no container is started. + cratedb = CrateDBContainer(cmd_opts=cmd_opts) + assert cratedb._command == expected diff --git a/uv.lock b/uv.lock index c02a55b29..1f7fca16a 100644 --- a/uv.lock +++ b/uv.lock @@ -1063,6 +1063,20 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "crate" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "orjson" }, + { name = "urllib3" }, + { name = "verlib2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/8a/6dec8617c4383c3e3a8a3943a50b9240f5532784249adb36c4922213052d/crate-2.1.2.tar.gz", hash = "sha256:a2b88ba1b236bc2fa627e151ade47cefdfea660f950e40134a5093ff2443ebe3", size = 67914, upload-time = "2026-03-09T15:09:30.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/d1/3f9cfc180bf8961f178895f940fec0d8f04249aaf61e00b7bac6a4b89ffa/crate-2.1.2-py3-none-any.whl", hash = "sha256:3353807d71e77cb0a7a5cdfd1a59b8c90276517b7f86988578fc6d15eefb635e", size = 32419, upload-time = "2026-03-09T15:09:29.519Z" }, +] + [[package]] name = "cryptography" version = "48.0.0" @@ -1385,6 +1399,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] +[[package]] +name = "geojson" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/a9/bd61eee2c7904947094b74866b569f3fd5a8d6ac907ecdfecef74b19d459/geojson-3.3.0.tar.gz", hash = "sha256:92e83b9cb378a450b42f1207bb9b2a031f9fc89185f335153c44369b8b8b71fd", size = 25141, upload-time = "2026-05-28T21:48:08.214Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/5e/fdd72167b57158d743353f71d453200719744d1e75f18b1c8230508db370/geojson-3.3.0-py3-none-any.whl", hash = "sha256:a2d885187eeaa8b357600b3fcc9d963cb4300d1694196636dbd7eddc82fd0825", size = 15181, upload-time = "2026-05-28T21:48:06.648Z" }, +] + [[package]] name = "geomet" version = "1.1.0" @@ -5387,6 +5410,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f2/f1/e08ec3fc57b6f890e8f6eb16734c4865626635f4863cc2fe7d259699ce7b/sqlalchemy_cockroachdb-2.0.4-py3-none-any.whl", hash = "sha256:0804306a733a1fe09b2d496eadf8bf35d878862b6138e69175136fa2ca0ce5d0", size = 22726, upload-time = "2026-04-23T18:12:26.936Z" }, ] +[[package]] +name = "sqlalchemy-cratedb" +version = "0.42.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "crate" }, + { name = "geojson" }, + { name = "sqlalchemy" }, + { name = "verlib2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/ac/e2732072194d6eb11cb2b948c3c31e5336a2e19e0c3caa04baa035974371/sqlalchemy_cratedb-0.42.0.tar.gz", hash = "sha256:e4f9530fe8204a338523ec230ad22ca692f64cc966c1da4f13b2215def923525", size = 46493, upload-time = "2026-05-28T15:14:43.282Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/c3/f32c4d021eb660ad703f35df0e926b52c01224d4e392387b7cf9d931dfff/sqlalchemy_cratedb-0.42.0-py3-none-any.whl", hash = "sha256:82a551c9b9156dde0b621287a56e95ebec3c80462359541820c2c6cb8c9a8a9b", size = 46054, upload-time = "2026-05-28T15:14:42.142Z" }, +] + [[package]] name = "tenacity" version = "9.1.4" @@ -5398,7 +5436,7 @@ wheels = [ [[package]] name = "testcontainers" -version = "4.15.0rc3" +version = "4.15.0rc4" source = { editable = "." } dependencies = [ { name = "docker" }, @@ -5554,6 +5592,7 @@ dev = [ { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "sqlalchemy" }, { name = "sqlalchemy-cockroachdb" }, + { name = "sqlalchemy-cratedb" }, { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "twine" }, { name = "types-docker" }, @@ -5595,6 +5634,7 @@ test = [ { name = "pytest-xdist" }, { name = "sqlalchemy" }, { name = "sqlalchemy-cockroachdb" }, + { name = "sqlalchemy-cratedb" }, { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "twine" }, ] @@ -5652,7 +5692,7 @@ requires-dist = [ { name = "weaviate-client", marker = "extra == 'weaviate'", specifier = ">=4" }, { name = "wrapt" }, ] -provides-extras = ["arangodb", "aws", "azurite", "cassandra", "clickhouse", "cosmosdb", "cockroachdb", "db2", "elasticsearch", "generic", "test-module-import", "google", "influxdb", "k3s", "kafka", "keycloak", "localstack", "mailpit", "memcached", "minio", "milvus", "mongodb", "mqtt", "mssql", "mysql", "nats", "neo4j", "nginx", "openfga", "opensearch", "ollama", "oracle", "oracle-free", "postgres", "qdrant", "rabbitmq", "redis", "registry", "selenium", "scylla", "sftp", "valkey", "vault", "weaviate", "chroma", "trino"] +provides-extras = ["arangodb", "aws", "azurite", "cassandra", "clickhouse", "cosmosdb", "cockroachdb", "cratedb", "db2", "elasticsearch", "generic", "test-module-import", "google", "influxdb", "k3s", "kafka", "keycloak", "localstack", "mailpit", "memcached", "minio", "milvus", "mongodb", "mqtt", "mssql", "mysql", "nats", "neo4j", "nginx", "openfga", "opensearch", "ollama", "oracle", "oracle-free", "postgres", "qdrant", "rabbitmq", "redis", "registry", "selenium", "scylla", "sftp", "valkey", "vault", "weaviate", "chroma", "trino"] [package.metadata.requires-dev] dev = [ @@ -5684,6 +5724,7 @@ dev = [ { name = "sphinx", marker = "python_full_version >= '3.11'", specifier = ">=9" }, { name = "sqlalchemy", specifier = ">=2" }, { name = "sqlalchemy-cockroachdb", specifier = ">=2" }, + { name = "sqlalchemy-cratedb" }, { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0" }, { name = "twine", specifier = ">=6.2.0" }, { name = "types-docker", specifier = ">=7.1.0.20260518" }, @@ -5724,6 +5765,7 @@ test = [ { name = "pytest-xdist", specifier = ">=3.8.0" }, { name = "sqlalchemy", specifier = ">=2" }, { name = "sqlalchemy-cockroachdb", specifier = ">=2" }, + { name = "sqlalchemy-cratedb" }, { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0" }, { name = "twine", specifier = ">=6.2.0" }, ] @@ -5974,6 +6016,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/6e/3e955517e22cbdd565f2f8b2e73d52528b14b8bcfdb04f62466b071de847/validators-0.35.0-py3-none-any.whl", hash = "sha256:e8c947097eae7892cb3d26868d637f79f47b4a0554bc6b80065dfe5aac3705dd", size = 44712, upload-time = "2025-05-01T05:42:04.203Z" }, ] +[[package]] +name = "verlib2" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/94/b3f689e44c14702ce67fdf24db649e8193958f8de971e69d024ef7e6b295/verlib2-0.3.2.tar.gz", hash = "sha256:a0a6af9838d8d26c18225a30c8d57fb70714462e3cd29f2451e7982d5a51e21e", size = 16987, upload-time = "2026-03-31T01:18:45.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/b2/5d2d39c1afb8b3904ed053dc4ec4a992f17c6a993e51e62a4af0d7878d00/verlib2-0.3.2-py3-none-any.whl", hash = "sha256:ddcc6f3a4f42019703ac5f6406faecefcd5f122e84a3d1a58190ddff23ec0426", size = 15054, upload-time = "2026-03-31T01:18:46.805Z" }, +] + [[package]] name = "virtualenv" version = "21.4.2"