Skip to content

Commit fc09dc1

Browse files
feat(valkey): add Valkey module (#947)
I’d like to add Valkey, the open-source fork of Redis, as a dedicated Testcontainers module. - Docker container: valkey/valkey:latest - Valkey website: https://valkey.io/ - Documentation: https://valkey.io/docs/ --------- Signed-off-by: Daria Korenieva <daric2612@gmail.com> Co-authored-by: Roy Moore <roy@moore.co.il>
1 parent 6ecf347 commit fc09dc1

File tree

8 files changed

+306
-2
lines changed

8 files changed

+306
-2
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.3"><span class="tc-version">:material-tag: v4.14.3</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: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""
2+
Valkey container usage examples with valkey-glide sync client.
3+
4+
Requires: pip install valkey-glide-sync
5+
"""
6+
7+
from glide_sync import GlideClient, GlideClientConfiguration, NodeAddress, ServerCredentials
8+
9+
from testcontainers.valkey import ValkeyContainer
10+
11+
12+
def basic_example():
13+
with ValkeyContainer() as valkey_container:
14+
host = valkey_container.get_host()
15+
port = valkey_container.get_exposed_port()
16+
connection_url = valkey_container.get_connection_url()
17+
18+
print(f"Valkey connection URL: {connection_url}")
19+
print(f"Host: {host}, Port: {port}")
20+
21+
config = GlideClientConfiguration([NodeAddress(host, port)])
22+
client = GlideClient.create(config)
23+
24+
pong = client.ping()
25+
print(f"PING response: {pong}")
26+
27+
client.set("key", "value")
28+
print("SET response: OK")
29+
30+
value = client.get("key")
31+
print(f"GET response: {value}")
32+
33+
client.close()
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+
config = GlideClientConfiguration(
45+
[NodeAddress(host, port)],
46+
credentials=ServerCredentials(password="mypassword"),
47+
)
48+
client = GlideClient.create(config)
49+
50+
pong = client.ping()
51+
print(f"PING response: {pong}")
52+
53+
client.close()
54+
55+
56+
def version_example():
57+
with ValkeyContainer().with_image_tag("8.0") as valkey_container:
58+
print(f"\nUsing image: {valkey_container.image}")
59+
connection_url = valkey_container.get_connection_url()
60+
print(f"Connection URL: {connection_url}")
61+
62+
63+
def bundle_example():
64+
with ValkeyContainer().with_bundle() as valkey_container:
65+
print(f"\nUsing bundle image: {valkey_container.image}")
66+
host = valkey_container.get_host()
67+
port = valkey_container.get_exposed_port()
68+
69+
config = GlideClientConfiguration([NodeAddress(host, port)])
70+
client = GlideClient.create(config)
71+
72+
pong = client.ping()
73+
print(f"PING response: {pong}")
74+
75+
client.close()
76+
77+
78+
if __name__ == "__main__":
79+
basic_example()
80+
password_example()
81+
version_example()
82+
bundle_example()
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
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+
from testcontainers.core.container import DockerContainer
15+
from testcontainers.core.wait_strategies import ExecWaitStrategy
16+
17+
_BASE_IMAGE = "valkey/valkey"
18+
_BUNDLE_IMAGE = "valkey/valkey-bundle"
19+
20+
21+
class ValkeyContainer(DockerContainer):
22+
"""
23+
Valkey container.
24+
25+
"""
26+
27+
def __init__(self, image: str = f"{_BASE_IMAGE}:latest", port: int = 6379, **kwargs) -> None:
28+
super().__init__(image, **kwargs)
29+
self.port = port
30+
self.password: str | None = None
31+
self.with_exposed_ports(self.port)
32+
self.waiting_for(ExecWaitStrategy(["valkey-cli", "ping"]))
33+
34+
def with_password(self, password: str) -> "ValkeyContainer":
35+
"""
36+
Configure authentication for Valkey.
37+
38+
Args:
39+
password: Password for Valkey authentication.
40+
41+
Returns:
42+
self: Container instance for method chaining.
43+
"""
44+
self.password = password
45+
self.with_command(["valkey-server", "--requirepass", password])
46+
self.waiting_for(ExecWaitStrategy(["valkey-cli", "-a", password, "ping"]))
47+
return self
48+
49+
def with_image_tag(self, tag: str) -> "ValkeyContainer":
50+
"""
51+
Specify Valkey version.
52+
53+
Args:
54+
tag: Image tag (e.g., '8.0', 'latest').
55+
56+
Returns:
57+
self: Container instance for method chaining.
58+
"""
59+
base_image = self.image.rsplit(":", 1)[0]
60+
self.image = f"{base_image}:{tag}"
61+
return self
62+
63+
def with_bundle(self) -> "ValkeyContainer":
64+
"""
65+
Enable all modules by switching to valkey-bundle image.
66+
67+
Returns:
68+
self: Container instance for method chaining.
69+
"""
70+
tag = self.image.rsplit(":", 1)[-1]
71+
self.image = f"{_BUNDLE_IMAGE}:{tag}"
72+
return self
73+
74+
def get_connection_url(self) -> str:
75+
"""
76+
Get connection URL for Valkey.
77+
78+
Returns:
79+
url: Connection URL in format valkey://[:password@]host:port
80+
"""
81+
host = self.get_host()
82+
port = self.get_exposed_port()
83+
if self.password:
84+
return f"valkey://:{self.password}@{host}:{port}"
85+
return f"valkey://{host}:{port}"
86+
87+
def get_host(self) -> str:
88+
"""
89+
Get container host.
90+
91+
Returns:
92+
host: Container host IP.
93+
"""
94+
return self.get_container_host_ip()
95+
96+
def get_exposed_port(self) -> int:
97+
"""
98+
Get mapped port.
99+
100+
Returns:
101+
port: Exposed port number.
102+
"""
103+
return int(super().get_exposed_port(self.port))
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 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 container.image == "valkey/valkey:8.0"
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_with_tag_and_bundle():
71+
container = ValkeyContainer().with_image_tag("8.0").with_bundle()
72+
assert container.image == "valkey/valkey-bundle:8.0"
73+
74+
75+
def test_bundle_starts():
76+
with ValkeyContainer().with_bundle() as valkey:
77+
host = valkey.get_host()
78+
port = valkey.get_exposed_port()
79+
80+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
81+
s.connect((host, port))
82+
s.sendall(b"*1\r\n$4\r\nPING\r\n")
83+
response = s.recv(1024)
84+
assert b"+PONG" in response

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ registry = ["bcrypt>=5"]
101101
selenium = ["selenium>=4"]
102102
scylla = ["cassandra-driver>=3; python_version < '3.14'"]
103103
sftp = ["cryptography"]
104+
valkey = []
104105
vault = []
105106
weaviate = ["weaviate-client>=4"]
106107
chroma = ["chromadb-client>=1"]
@@ -218,6 +219,7 @@ packages = [
218219
"modules/selenium/testcontainers",
219220
"modules/scylla/testcontainers",
220221
"modules/trino/testcontainers",
222+
"modules/valkey/testcontainers",
221223
"modules/vault/testcontainers",
222224
"modules/weaviate/testcontainers",
223225
]
@@ -267,6 +269,7 @@ dev-mode-dirs = [
267269
"modules/selenium",
268270
"modules/scylla",
269271
"modules/trino",
272+
"modules/valkey",
270273
"modules/vault",
271274
"modules/weaviate",
272275
]

0 commit comments

Comments
 (0)