Skip to content

Commit bf1d904

Browse files
committed
valkey test container
1 parent c785ecd commit bf1d904

File tree

8 files changed

+331
-1
lines changed

8 files changed

+331
-1
lines changed

docs/modules/valkey.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Valkey
2+
3+
Since testcontainers-python <a href="https://github.com/testcontainers/testcontainers-python/releases/tag/v4.14.0"><span class="tc-version">:material-tag: v4.14.0</span></a>
4+
5+
## Introduction
6+
7+
The Testcontainers module for Valkey.
8+
9+
## Adding this module to your project dependencies
10+
11+
Please run the following command to add the Valkey module to your python dependencies:
12+
13+
```bash
14+
pip install testcontainers[valkey]
15+
```
16+
17+
## Usage example
18+
19+
<!--codeinclude-->
20+
21+
[Creating a Valkey container](../../modules/valkey/example_basic.py)
22+
23+
<!--/codeinclude-->

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ nav:
7272
- modules/redis.md
7373
- modules/scylla.md
7474
- modules/trino.md
75+
- modules/valkey.md
7576
- modules/weaviate.md
7677
- modules/aws.md
7778
- modules/azurite.md

modules/valkey/README.rst

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

modules/valkey/example_basic.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import socket
2+
3+
from testcontainers.valkey import ValkeyContainer
4+
5+
6+
def basic_example():
7+
with ValkeyContainer() as valkey_container:
8+
# Get connection parameters
9+
host = valkey_container.get_host()
10+
port = valkey_container.get_exposed_port()
11+
connection_url = valkey_container.get_connection_url()
12+
13+
print(f"Valkey connection URL: {connection_url}")
14+
print(f"Host: {host}, Port: {port}")
15+
16+
# Connect using raw socket and RESP protocol
17+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
18+
s.connect((host, port))
19+
20+
# PING command
21+
s.sendall(b"*1\r\n$4\r\nPING\r\n")
22+
response = s.recv(1024)
23+
print(f"PING response: {response.decode()}")
24+
25+
# SET command
26+
s.sendall(b"*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n")
27+
response = s.recv(1024)
28+
print(f"SET response: {response.decode()}")
29+
30+
# GET command
31+
s.sendall(b"*2\r\n$3\r\nGET\r\n$3\r\nkey\r\n")
32+
response = s.recv(1024)
33+
print(f"GET response: {response.decode()}")
34+
35+
36+
def password_example():
37+
with ValkeyContainer().with_password("mypassword") as valkey_container:
38+
host = valkey_container.get_host()
39+
port = valkey_container.get_exposed_port()
40+
connection_url = valkey_container.get_connection_url()
41+
42+
print(f"\nValkey with password connection URL: {connection_url}")
43+
44+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
45+
s.connect((host, port))
46+
47+
# AUTH command
48+
s.sendall(b"*2\r\n$4\r\nAUTH\r\n$10\r\nmypassword\r\n")
49+
response = s.recv(1024)
50+
print(f"AUTH response: {response.decode()}")
51+
52+
# PING after auth
53+
s.sendall(b"*1\r\n$4\r\nPING\r\n")
54+
response = s.recv(1024)
55+
print(f"PING response: {response.decode()}")
56+
57+
58+
def version_example():
59+
# Using specific version
60+
with ValkeyContainer().with_image_tag("8.0") as valkey_container:
61+
print(f"\nUsing image: {valkey_container.image}")
62+
connection_url = valkey_container.get_connection_url()
63+
print(f"Connection URL: {connection_url}")
64+
65+
66+
def bundle_example():
67+
# Using bundle with all modules (JSON, Bloom, Search, etc.)
68+
with ValkeyContainer().with_bundle() as valkey_container:
69+
print(f"\nUsing bundle image: {valkey_container.image}")
70+
host = valkey_container.get_host()
71+
port = valkey_container.get_exposed_port()
72+
73+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
74+
s.connect((host, port))
75+
s.sendall(b"*1\r\n$4\r\nPING\r\n")
76+
response = s.recv(1024)
77+
print(f"PING response: {response.decode()}")
78+
79+
80+
if __name__ == "__main__":
81+
basic_example()
82+
password_example()
83+
version_example()
84+
bundle_example()
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
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+
14+
import socket
15+
from typing import Optional
16+
17+
from testcontainers.core.container import DockerContainer
18+
from testcontainers.core.waiting_utils import wait_container_is_ready
19+
20+
21+
class ValkeyNotReady(Exception):
22+
pass
23+
24+
25+
class ValkeyContainer(DockerContainer):
26+
"""
27+
Valkey container.
28+
29+
Example:
30+
31+
.. doctest::
32+
33+
>>> from testcontainers.valkey import ValkeyContainer
34+
35+
>>> with ValkeyContainer() as valkey_container:
36+
... connection_url = valkey_container.get_connection_url()
37+
"""
38+
39+
def __init__(self, image: str = "valkey/valkey:latest", port: int = 6379, **kwargs) -> None:
40+
super().__init__(image, **kwargs)
41+
self.port = port
42+
self.password: Optional[str] = None
43+
self.with_exposed_ports(self.port)
44+
45+
def with_password(self, password: str) -> "ValkeyContainer":
46+
"""
47+
Configure authentication for Valkey.
48+
49+
Args:
50+
password: Password for Valkey authentication.
51+
52+
Returns:
53+
self: Container instance for method chaining.
54+
"""
55+
self.password = password
56+
self.with_command(f"valkey-server --requirepass {password}")
57+
return self
58+
59+
def with_image_tag(self, tag: str) -> "ValkeyContainer":
60+
"""
61+
Specify Valkey version.
62+
63+
Args:
64+
tag: Image tag (e.g., '8.0', 'latest', 'bundle:latest').
65+
66+
Returns:
67+
self: Container instance for method chaining.
68+
"""
69+
base_image = self.image.split(":")[0]
70+
self.image = f"{base_image}:{tag}"
71+
return self
72+
73+
def with_bundle(self) -> "ValkeyContainer":
74+
"""
75+
Enable all modules by switching to valkey-bundle image.
76+
77+
Returns:
78+
self: Container instance for method chaining.
79+
"""
80+
self.image = self.image.replace("valkey/valkey", "valkey/valkey-bundle")
81+
return self
82+
83+
def get_connection_url(self) -> str:
84+
"""
85+
Get connection URL for Valkey.
86+
87+
Returns:
88+
url: Connection URL in format valkey://[:password@]host:port
89+
"""
90+
host = self.get_host()
91+
port = self.get_exposed_port()
92+
if self.password:
93+
return f"valkey://:{self.password}@{host}:{port}"
94+
return f"valkey://{host}:{port}"
95+
96+
def get_host(self) -> str:
97+
"""
98+
Get container host.
99+
100+
Returns:
101+
host: Container host IP.
102+
"""
103+
return self.get_container_host_ip()
104+
105+
def get_exposed_port(self) -> int:
106+
"""
107+
Get mapped port.
108+
109+
Returns:
110+
port: Exposed port number.
111+
"""
112+
return int(super().get_exposed_port(self.port))
113+
114+
@wait_container_is_ready(ValkeyNotReady)
115+
def _connect(self) -> None:
116+
"""Wait for Valkey to be ready by sending PING command."""
117+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
118+
s.connect((self.get_host(), self.get_exposed_port()))
119+
if self.password:
120+
s.sendall(f"*2\r\n$4\r\nAUTH\r\n${len(self.password)}\r\n{self.password}\r\n".encode())
121+
auth_response = s.recv(1024)
122+
if b"+OK" not in auth_response:
123+
raise ValkeyNotReady("Authentication failed")
124+
s.sendall(b"*1\r\n$4\r\nPING\r\n")
125+
response = s.recv(1024)
126+
if b"+PONG" not in response:
127+
raise ValkeyNotReady("Valkey not ready yet")
128+
129+
def start(self) -> "ValkeyContainer":
130+
"""
131+
Start the container and wait for it to be ready.
132+
133+
Returns:
134+
self: Started container instance.
135+
"""
136+
super().start()
137+
self._connect()
138+
return self
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import socket
2+
3+
from testcontainers.valkey import ValkeyContainer
4+
5+
6+
def test_docker_run_valkey():
7+
with ValkeyContainer() as valkey:
8+
host = valkey.get_host()
9+
port = valkey.get_exposed_port()
10+
11+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
12+
s.connect((host, port))
13+
s.sendall(b"*1\r\n$4\r\nPING\r\n")
14+
response = s.recv(1024)
15+
assert b"+PONG" in response
16+
17+
18+
def test_docker_run_valkey_with_password():
19+
with ValkeyContainer().with_password("mypass") as valkey:
20+
host = valkey.get_host()
21+
port = valkey.get_exposed_port()
22+
23+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
24+
s.connect((host, port))
25+
# Authenticate
26+
s.sendall(b"*2\r\n$4\r\nAUTH\r\n$6\r\nmypass\r\n")
27+
auth_response = s.recv(1024)
28+
assert b"+OK" in auth_response
29+
30+
# Test SET command
31+
s.sendall(b"*3\r\n$3\r\nSET\r\n$5\r\nhello\r\n$5\r\nworld\r\n")
32+
set_response = s.recv(1024)
33+
assert b"+OK" in set_response
34+
35+
# Test GET command
36+
s.sendall(b"*2\r\n$3\r\nGET\r\n$5\r\nhello\r\n")
37+
get_response = s.recv(1024)
38+
assert b"world" in get_response
39+
40+
41+
def test_get_connection_url():
42+
with ValkeyContainer() as valkey:
43+
url = valkey.get_connection_url()
44+
assert url.startswith("valkey://")
45+
assert str(valkey.get_exposed_port()) in url
46+
47+
48+
def test_get_connection_url_with_password():
49+
with ValkeyContainer().with_password("secret") as valkey:
50+
url = valkey.get_connection_url()
51+
assert url.startswith("valkey://:secret@")
52+
assert str(valkey.get_exposed_port()) in url
53+
54+
55+
def test_with_image_tag():
56+
container = ValkeyContainer().with_image_tag("8.0")
57+
assert "valkey/valkey:8.0" in container.image
58+
59+
60+
def test_with_bundle():
61+
container = ValkeyContainer().with_bundle()
62+
assert container.image == "valkey/valkey-bundle:latest"
63+
64+
65+
def test_with_bundle_and_tag():
66+
container = ValkeyContainer().with_bundle().with_image_tag("9.0")
67+
assert container.image == "valkey/valkey-bundle:9.0"
68+
69+
70+
def test_bundle_starts():
71+
with ValkeyContainer().with_bundle() as valkey:
72+
host = valkey.get_host()
73+
port = valkey.get_exposed_port()
74+
75+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
76+
s.connect((host, port))
77+
s.sendall(b"*1\r\n$4\r\nPING\r\n")
78+
response = s.recv(1024)
79+
assert b"+PONG" in response

poetry.lock

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ packages = [
7373
{ include = "testcontainers", from = "modules/selenium" },
7474
{ include = "testcontainers", from = "modules/scylla" },
7575
{ include = "testcontainers", from = "modules/trino" },
76+
{ include = "testcontainers", from = "modules/valkey" },
7677
{ include = "testcontainers", from = "modules/vault" },
7778
{ include = "testcontainers", from = "modules/weaviate" },
7879
]
@@ -188,6 +189,7 @@ rabbitmq = ["pika"]
188189
redis = ["redis"]
189190
registry = ["bcrypt"]
190191
selenium = ["selenium"]
192+
valkey = []
191193
scylla = ["cassandra-driver"]
192194
sftp = ["cryptography"]
193195
vault = []

0 commit comments

Comments
 (0)