Skip to content

Commit 0976c7e

Browse files
florinutzsuristeramotl
authored
fix(cratedb): add CrateDB community module (#1051)
Add a CrateDB module under testcontainers.community.cratedb, ported from #888 and built on the generic SqlContainer base reintroduced in #892. - CrateDBContainer extends community.generic.sql.SqlContainer; single-node command, HTTP (4200) wait strategy so startup needs no DB client library, crate:// SQLAlchemy connection URL - deprecation shim at testcontainers.cratedb - tests under tests/community/cratedb, docs, mkdocs nav entry, and the `cratedb` optional-dependency (empty; sqlalchemy-cratedb is a test-group dep) Supersedes #888 --------- Co-authored-by: surister <surister98@gmail.com> Co-authored-by: Andreas Motl <andreas.motl@elmyra.de>
1 parent 27542f4 commit 0976c7e

9 files changed

Lines changed: 317 additions & 1 deletion

File tree

docs/community/cratedb.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
.. autoclass:: testcontainers.community.cratedb.CrateDBContainer
2+
.. title:: testcontainers.community.cratedb.CrateDBContainer

docs/modules/cratedb.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# CrateDB
2+
3+
## Introduction
4+
5+
The Testcontainers module for [CrateDB](https://cratedb.com), a distributed SQL
6+
database for real-time analytics. CrateDB is PostgreSQL wire-compatible and
7+
provides a SQLAlchemy dialect (`crate://`) that talks to its HTTP interface.
8+
9+
## Adding this module to your project dependencies
10+
11+
Please run the following command to add the CrateDB module to your python dependencies:
12+
13+
```bash
14+
pip install testcontainers[cratedb] sqlalchemy sqlalchemy-cratedb
15+
```
16+
17+
## Usage example
18+
19+
<!--codeinclude-->
20+
21+
[Creating a CrateDB container](cratedb_example.py)
22+
23+
<!--/codeinclude-->
24+
25+
## Configuration
26+
27+
The CrateDB container can be configured with the following parameters:
28+
29+
- `image`: Docker image to use (default: `"crate/crate:latest"`)
30+
- `port`: container port used to build the connection URL (default: `4200`, the HTTP interface)
31+
- `username`: Database username (default: `"crate"`, or the `CRATEDB_USER` env var)
32+
- `password`: Database password (default: `"crate"`, or the `CRATEDB_PASSWORD` env var)
33+
- `dialect`: SQLAlchemy dialect used in the connection URL (default: `"crate"`)
34+
- `cmd_opts`: extra `-C<key>=<value>` options passed to CrateDB (merged over the single-node defaults)

docs/modules/cratedb_example.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import sqlalchemy
2+
from sqlalchemy import text
3+
4+
from testcontainers.community.cratedb import CrateDBContainer
5+
6+
7+
def basic_example():
8+
with CrateDBContainer("crate:latest") as cratedb:
9+
# CrateDB speaks the SQLAlchemy `crate://` dialect over its HTTP interface.
10+
engine = sqlalchemy.create_engine(cratedb.get_connection_url())
11+
12+
with engine.begin() as conn:
13+
conn.execute(
14+
text("""
15+
CREATE TABLE IF NOT EXISTS summits (
16+
name TEXT PRIMARY KEY,
17+
height INT
18+
)
19+
""")
20+
)
21+
print("Created table")
22+
23+
conn.execute(
24+
text("INSERT INTO summits (name, height) VALUES (:name, :height)"),
25+
[
26+
{"name": "Mont Blanc", "height": 4808},
27+
{"name": "Monte Rosa", "height": 4634},
28+
{"name": "Dom", "height": 4545},
29+
],
30+
)
31+
# CrateDB is eventually consistent for reads; refresh to read-your-writes.
32+
conn.execute(text("REFRESH TABLE summits"))
33+
print("Inserted data")
34+
35+
with engine.connect() as conn:
36+
result = conn.execute(text("SELECT name, height FROM summits ORDER BY height DESC"))
37+
print("\nQuery results:")
38+
for row in result:
39+
print(f"Name: {row.name}, Height: {row.height}")
40+
41+
42+
if __name__ == "__main__":
43+
basic_example()

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ nav:
5858
- modules/clickhouse.md
5959
- modules/cockroachdb.md
6060
- modules/cosmosdb.md
61+
- modules/cratedb.md
6162
- modules/db2.md
6263
- modules/elasticsearch.md
6364
- modules/influxdb.md

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ cassandra = []
5454
clickhouse = ["clickhouse-driver"]
5555
cosmosdb = ["azure-cosmos>=4"]
5656
cockroachdb = []
57+
cratedb = [
58+
"httpx",
59+
"sqlalchemy-cratedb"
60+
]
5761
db2 = [
5862
"sqlalchemy>=2",
5963
"ibm_db_sa; platform_machine != 'aarch64' and platform_machine != 'arm64'",
@@ -128,6 +132,7 @@ test = [
128132
"pymilvus>=2",
129133
"paho-mqtt>=2",
130134
"sqlalchemy-cockroachdb>=2",
135+
"sqlalchemy-cratedb",
131136
"paramiko>=4",
132137
"twine>=6.2.0",
133138
"anyio>=4",
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
#
2+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
3+
# not use this file except in compliance with the License. You may obtain
4+
# a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
10+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
11+
# License for the specific language governing permissions and limitations
12+
# under the License.
13+
import os
14+
from typing import ClassVar, Optional
15+
16+
from testcontainers.community.generic.sql import SqlContainer
17+
from testcontainers.core.utils import raise_for_deprecated_parameter
18+
from testcontainers.core.wait_strategies import HttpWaitStrategy
19+
20+
# CrateDB's HTTP interface. The SQLAlchemy `crate://` dialect talks to CrateDB
21+
# over HTTP, so this is the port used for connection URLs and readiness checks.
22+
HTTP_PORT = 4200
23+
# CrateDB's PostgreSQL wire-protocol interface, exposed for convenience.
24+
PSQL_PORT = 5432
25+
26+
27+
class CrateDBContainer(SqlContainer):
28+
"""
29+
CrateDB database container.
30+
31+
Example:
32+
33+
The example spins up a CrateDB database and connects to it using
34+
SQLAlchemy and the ``sqlalchemy-cratedb`` dialect, which talks to
35+
CrateDB over its HTTP interface (port 4200).
36+
37+
.. doctest::
38+
39+
>>> from testcontainers.community.cratedb import CrateDBContainer
40+
>>> import sqlalchemy
41+
42+
>>> with CrateDBContainer("crate:5.10") as cratedb:
43+
... engine = sqlalchemy.create_engine(cratedb.get_connection_url())
44+
... with engine.begin() as connection:
45+
... result = connection.execute(sqlalchemy.text("select name from sys.cluster"))
46+
... cluster_name, = result.fetchone()
47+
"""
48+
49+
# Default command-line options. CrateDB needs single-node discovery to run
50+
# as a one-node cluster suitable for testing.
51+
CMD_OPTS: ClassVar[list[tuple[str, str]]] = [("discovery.type", "single-node")]
52+
53+
def __init__(
54+
self,
55+
image: str = "crate/crate:latest",
56+
port: int = HTTP_PORT,
57+
username: Optional[str] = None,
58+
password: Optional[str] = None,
59+
dialect: str = "crate",
60+
cmd_opts: Optional[list[tuple[str, str]]] = None,
61+
**kwargs,
62+
) -> None:
63+
"""
64+
:param image: Docker image name (with optional tag).
65+
:param port: container port used to build the connection URL; defaults
66+
to the HTTP port (4200) used by the ``crate://`` dialect.
67+
:param username: username for the DB; falls back to the ``CRATEDB_USER``
68+
environment variable, then ``crate``.
69+
:param password: password for the DB; falls back to the
70+
``CRATEDB_PASSWORD`` environment variable, then ``crate``.
71+
:param dialect: SQLAlchemy dialect used in the connection URL.
72+
:param cmd_opts: extra ``-C<key>=<value>`` options passed to CrateDB,
73+
merged over (and able to override) the defaults.
74+
"""
75+
raise_for_deprecated_parameter(kwargs, "user", "username")
76+
# Readiness is signaled by CrateDB's HTTP interface returning 200; this
77+
# keeps startup free of any database client library.
78+
super().__init__(image, wait_strategy=HttpWaitStrategy(HTTP_PORT).for_status_code(200), **kwargs)
79+
80+
cmd_opts = cmd_opts or []
81+
default_cmd_opts = [s for s in self.CMD_OPTS if s[0] not in {k[0] for k in cmd_opts}]
82+
self._command = self._build_cmd([*default_cmd_opts, *cmd_opts])
83+
84+
self.username = username or os.environ.get("CRATEDB_USER", "crate")
85+
self.password = password or os.environ.get("CRATEDB_PASSWORD", "crate")
86+
self.port = port
87+
self.dialect = dialect
88+
89+
self.with_exposed_ports(HTTP_PORT, PSQL_PORT)
90+
91+
@staticmethod
92+
def _build_cmd(opts: list[tuple[str, str]]) -> str:
93+
"""Render a CrateDB ``-C<key>=<value> ...`` command-line string."""
94+
cmd = []
95+
for key, val in opts:
96+
if isinstance(val, bool):
97+
val = str(val).lower()
98+
cmd.append(f"-C{key}={val}")
99+
return " ".join(cmd)
100+
101+
def _configure(self) -> None:
102+
self.with_env("CRATEDB_USER", self.username)
103+
self.with_env("CRATEDB_PASSWORD", self.password)
104+
105+
def get_connection_url(self, host: Optional[str] = None) -> str:
106+
return self._create_connection_url(
107+
dialect=self.dialect,
108+
username=self.username,
109+
password=self.password,
110+
host=host,
111+
port=self.port,
112+
)

src/testcontainers/cratedb.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import warnings
2+
3+
from testcontainers.community.cratedb import (
4+
CrateDBContainer,
5+
)
6+
7+
warnings.warn(
8+
"testcontainers.cratedb is deprecated, use testcontainers.community.cratedb instead",
9+
DeprecationWarning,
10+
stacklevel=2,
11+
)
12+
13+
__all__ = [
14+
"CrateDBContainer",
15+
]
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import urllib.parse
2+
3+
import pytest
4+
import sqlalchemy
5+
6+
from testcontainers.community.cratedb import CrateDBContainer
7+
8+
9+
@pytest.mark.parametrize("version", ["5.10", "latest"])
10+
def test_docker_run_cratedb(version: str):
11+
with CrateDBContainer(f"crate:{version}") as cratedb:
12+
engine = sqlalchemy.create_engine(cratedb.get_connection_url())
13+
with engine.begin() as connection:
14+
result = connection.execute(sqlalchemy.text("select 1 + 2 + 3 + 4 + 5"))
15+
assert result.fetchone()[0] == 15
16+
17+
18+
def test_cratedb_connection_url():
19+
with CrateDBContainer("crate:latest", username="crate", password="crate") as cratedb:
20+
url = urllib.parse.urlparse(cratedb.get_connection_url())
21+
assert url.scheme == "crate"
22+
credentials, location = url.netloc.split("@")
23+
assert credentials == "crate:crate"
24+
host, port = location.split(":")
25+
assert host == cratedb.get_container_host_ip()
26+
assert int(port) == cratedb.get_exposed_port(cratedb.port)
27+
28+
29+
@pytest.mark.parametrize(
30+
"cmd_opts, expected",
31+
[
32+
pytest.param(
33+
[("indices.breaker.total.limit", "90%")],
34+
"-Cdiscovery.type=single-node -Cindices.breaker.total.limit=90%",
35+
id="add_cmd_option",
36+
),
37+
pytest.param(
38+
[("discovery.type", "zen"), ("indices.breaker.total.limit", "90%")],
39+
"-Cdiscovery.type=zen -Cindices.breaker.total.limit=90%",
40+
id="override_defaults",
41+
),
42+
],
43+
)
44+
def test_build_command(cmd_opts, expected):
45+
# Pure unit test: the command line is assembled in __init__, no container is started.
46+
cratedb = CrateDBContainer(cmd_opts=cmd_opts)
47+
assert cratedb._command == expected

0 commit comments

Comments
 (0)