diff --git a/docs/modules/valkey.md b/docs/modules/valkey.md new file mode 100644 index 000000000..fbfea9ed7 --- /dev/null +++ b/docs/modules/valkey.md @@ -0,0 +1,23 @@ +# Valkey + +Since testcontainers-python :material-tag: v4.14.0 + +## Introduction + +The Testcontainers module for Valkey. + +## Adding this module to your project dependencies + +Please run the following command to add the Valkey module to your python dependencies: + +```bash +pip install testcontainers[valkey] +``` + +## Usage example + + + +[Creating a Valkey container](../../modules/valkey/example_basic.py) + + diff --git a/mkdocs.yml b/mkdocs.yml index aca8281b7..0a31629a2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -72,6 +72,7 @@ nav: - modules/redis.md - modules/scylla.md - modules/trino.md + - modules/valkey.md - modules/weaviate.md - modules/aws.md - modules/azurite.md diff --git a/modules/valkey/README.rst b/modules/valkey/README.rst new file mode 100644 index 000000000..abe0c74e1 --- /dev/null +++ b/modules/valkey/README.rst @@ -0,0 +1,2 @@ +.. autoclass:: testcontainers.valkey.ValkeyContainer +.. title:: testcontainers.valkey.ValkeyContainer diff --git a/modules/valkey/example_basic.py b/modules/valkey/example_basic.py new file mode 100644 index 000000000..593a729b8 --- /dev/null +++ b/modules/valkey/example_basic.py @@ -0,0 +1,78 @@ +from glide import GlideClient, NodeAddress + +from testcontainers.valkey import ValkeyContainer + + +def basic_example(): + with ValkeyContainer() as valkey_container: + # Get connection parameters + host = valkey_container.get_host() + port = valkey_container.get_exposed_port() + connection_url = valkey_container.get_connection_url() + + print(f"Valkey connection URL: {connection_url}") + print(f"Host: {host}, Port: {port}") + + # Connect using Glide client + client = GlideClient([NodeAddress(host, port)]) + + # PING command + pong = client.ping() + print(f"PING response: {pong}") + + # SET command + client.set("key", "value") + print("SET response: OK") + + # GET command + value = client.get("key") + print(f"GET response: {value}") + + client.close() + + +def password_example(): + with ValkeyContainer().with_password("mypassword") as valkey_container: + host = valkey_container.get_host() + port = valkey_container.get_exposed_port() + connection_url = valkey_container.get_connection_url() + + print(f"\nValkey with password connection URL: {connection_url}") + + # Connect using Glide client with password + client = GlideClient([NodeAddress(host, port)], password="mypassword") + + # PING after auth + pong = client.ping() + print(f"PING response: {pong}") + + client.close() + + +def version_example(): + # Using specific version + with ValkeyContainer().with_image_tag("8.0") as valkey_container: + print(f"\nUsing image: {valkey_container.image}") + connection_url = valkey_container.get_connection_url() + print(f"Connection URL: {connection_url}") + + +def bundle_example(): + # Using bundle with all modules (JSON, Bloom, Search, etc.) + with ValkeyContainer().with_bundle() as valkey_container: + print(f"\nUsing bundle image: {valkey_container.image}") + host = valkey_container.get_host() + port = valkey_container.get_exposed_port() + + # Connect using Glide client + client = GlideClient([NodeAddress(host, port)]) + pong = client.ping() + print(f"PING response: {pong}") + client.close() + + +if __name__ == "__main__": + basic_example() + password_example() + version_example() + bundle_example() diff --git a/modules/valkey/testcontainers/valkey/__init__.py b/modules/valkey/testcontainers/valkey/__init__.py new file mode 100644 index 000000000..1ee0c243c --- /dev/null +++ b/modules/valkey/testcontainers/valkey/__init__.py @@ -0,0 +1,114 @@ +# +# 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 typing import Optional + +from testcontainers.core.container import DockerContainer +from testcontainers.core.wait_strategies import ExecWaitStrategy + + +class ValkeyContainer(DockerContainer): + """ + Valkey container. + + """ + + def __init__(self, image: str = "valkey/valkey:latest", port: int = 6379, **kwargs) -> None: + super().__init__(image, **kwargs) + self.port = port + self.password: Optional[str] = None + self.with_exposed_ports(self.port) + + def with_password(self, password: str) -> "ValkeyContainer": + """ + Configure authentication for Valkey. + + Args: + password: Password for Valkey authentication. + + Returns: + self: Container instance for method chaining. + """ + self.password = password + self.with_command(["valkey-server", "--requirepass", password]) + return self + + def with_image_tag(self, tag: str) -> "ValkeyContainer": + """ + Specify Valkey version. + + Args: + tag: Image tag (e.g., '8.0', 'latest', 'bundle:latest'). + + Returns: + self: Container instance for method chaining. + """ + base_image = self.image.split(":")[0] + self.image = f"{base_image}:{tag}" + return self + + def with_bundle(self) -> "ValkeyContainer": + """ + Enable all modules by switching to valkey-bundle image. + + Returns: + self: Container instance for method chaining. + """ + self.image = self.image.replace("valkey/valkey", "valkey/valkey-bundle") + return self + + def get_connection_url(self) -> str: + """ + Get connection URL for Valkey. + + Returns: + url: Connection URL in format valkey://[:password@]host:port + """ + host = self.get_host() + port = self.get_exposed_port() + if self.password: + return f"valkey://:{self.password}@{host}:{port}" + return f"valkey://{host}:{port}" + + def get_host(self) -> str: + """ + Get container host. + + Returns: + host: Container host IP. + """ + return self.get_container_host_ip() + + def get_exposed_port(self) -> int: + """ + Get mapped port. + + Returns: + port: Exposed port number. + """ + return int(super().get_exposed_port(self.port)) + + def start(self) -> "ValkeyContainer": + """ + Start the container and wait for it to be ready. + + Returns: + self: Started container instance. + """ + if self.password: + self.waiting_for(ExecWaitStrategy(["valkey-cli", "-a", self.password, "ping"])) + else: + self.waiting_for(ExecWaitStrategy(["valkey-cli", "ping"])) + + super().start() + return self diff --git a/modules/valkey/tests/test_valkey.py b/modules/valkey/tests/test_valkey.py new file mode 100644 index 000000000..6a2c76b1a --- /dev/null +++ b/modules/valkey/tests/test_valkey.py @@ -0,0 +1,79 @@ +import socket + +from testcontainers.valkey import ValkeyContainer + + +def test_docker_run_valkey(): + with ValkeyContainer() as valkey: + host = valkey.get_host() + port = valkey.get_exposed_port() + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.connect((host, port)) + s.sendall(b"*1\r\n$4\r\nPING\r\n") + response = s.recv(1024) + assert b"+PONG" in response + + +def test_docker_run_valkey_with_password(): + with ValkeyContainer().with_password("mypass") as valkey: + host = valkey.get_host() + port = valkey.get_exposed_port() + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.connect((host, port)) + # Authenticate + s.sendall(b"*2\r\n$4\r\nAUTH\r\n$6\r\nmypass\r\n") + auth_response = s.recv(1024) + assert b"+OK" in auth_response + + # Test SET command + s.sendall(b"*3\r\n$3\r\nSET\r\n$5\r\nhello\r\n$5\r\nworld\r\n") + set_response = s.recv(1024) + assert b"+OK" in set_response + + # Test GET command + s.sendall(b"*2\r\n$3\r\nGET\r\n$5\r\nhello\r\n") + get_response = s.recv(1024) + assert b"world" in get_response + + +def test_get_connection_url(): + with ValkeyContainer() as valkey: + url = valkey.get_connection_url() + assert url.startswith("valkey://") + assert str(valkey.get_exposed_port()) in url + + +def test_get_connection_url_with_password(): + with ValkeyContainer().with_password("secret") as valkey: + url = valkey.get_connection_url() + assert url.startswith("valkey://:secret@") + assert str(valkey.get_exposed_port()) in url + + +def test_with_image_tag(): + container = ValkeyContainer().with_image_tag("8.0") + assert "valkey/valkey:8.0" in container.image + + +def test_with_bundle(): + container = ValkeyContainer().with_bundle() + assert container.image == "valkey/valkey-bundle:latest" + + +def test_with_bundle_and_tag(): + container = ValkeyContainer().with_bundle().with_image_tag("9.0") + assert container.image == "valkey/valkey-bundle:9.0" + + +def test_bundle_starts(): + with ValkeyContainer().with_bundle() as valkey: + host = valkey.get_host() + port = valkey.get_exposed_port() + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.connect((host, port)) + s.sendall(b"*1\r\n$4\r\nPING\r\n") + response = s.recv(1024) + assert b"+PONG" in response diff --git a/poetry.lock b/poetry.lock index dc6f2843c..67be04818 100644 --- a/poetry.lock +++ b/poetry.lock @@ -7504,10 +7504,11 @@ selenium = ["selenium"] sftp = ["cryptography"] test-module-import = ["httpx"] trino = ["trino"] +valkey = [] vault = [] weaviate = ["weaviate-client"] [metadata] lock-version = "2.1" python-versions = ">=3.9.2" -content-hash = "9a3a047c18407dec1b8e4add0c59b44b9613f208803e4ca83abfb3c60c1c757f" +content-hash = "c14a70b6a29adf2ca61116c939d79c2ae9abb5e5a03610607f1d927bc32260e2" diff --git a/pyproject.toml b/pyproject.toml index 1a0231c51..52a665f3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,6 +73,7 @@ packages = [ { include = "testcontainers", from = "modules/selenium" }, { include = "testcontainers", from = "modules/scylla" }, { include = "testcontainers", from = "modules/trino" }, + { include = "testcontainers", from = "modules/valkey" }, { include = "testcontainers", from = "modules/vault" }, { include = "testcontainers", from = "modules/weaviate" }, ] @@ -188,6 +189,7 @@ rabbitmq = ["pika"] redis = ["redis"] registry = ["bcrypt"] selenium = ["selenium"] +valkey = [] scylla = ["cassandra-driver"] sftp = ["cryptography"] vault = []