Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/ci/provider-groups.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"tests/test_elasticsearch.py::test_plugin_imports_without_elasticsearch_clients",
"tests/test_mongodb.py::test_plugin_imports_without_pymongo",
"tests/test_mssql.py::test_plugin_imports_without_pymssql",
"tests/test_redis.py::test_plugin_imports_without_redis",
"tests/test_spanner.py::test_plugin_imports_without_google_cloud_spanner",
"tests/test_valkey.py::test_plugin_imports_without_valkey"
],
Expand Down
31 changes: 19 additions & 12 deletions docs/supported-databases/redis.rst
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
Redis
=====

Integration with `Redis <https://redis.io/>`_ using the `Redis Docker Image <https://hub.docker.com/_/redis>`_, Snap's `Key DB<https://docs.keydb.dev/>` or `Dragonfly <https://www.dragonflydb.io/>`_.
Integration with `Redis <https://redis.io/>`_ using the `Redis Docker Image <https://hub.docker.com/_/redis>`_, `KeyDB <https://docs.keydb.dev/>`_, or `Dragonfly <https://www.dragonflydb.io/>`_. KeyDB and Dragonfly are wire-compatible with Redis, so a ``redis.Redis`` client works against all three services.

Installation
------------

.. code-block:: bash

pip install pytest-databases[redis]
pip install pytest-databases[redis] redis

The ``redis`` package is no longer pulled by the ``pytest-databases[redis]`` extra — the fixtures validate the container via ``redis-cli`` invoked from a short-lived sidecar — so install your own client (``redis``, ``redis[hiredis]``, etc.) alongside ``pytest-databases``.

Usage Example
-------------
Expand All @@ -21,32 +23,37 @@ Usage Example

pytest_plugins = ["pytest_databases.docker.redis"]

def test(redis_service: RedisService) -> None:
def test_redis(redis_service: RedisService) -> None:
client = redis.Redis(
host=redis_service.host,
port=redis_service.port,
db=redis_service.db
db=redis_service.db,
)
client.set("test_key", "test_value")
assert client.get("test_key") == b"test_value"

def test(redis_connection: redis.Redis) -> None:
redis_connection.set("test_key", "test_value")
assert redis_connection.get("test_key") == b"test_value"
def test_keydb(keydb_service: RedisService) -> None:
client = redis.Redis(host=keydb_service.host, port=keydb_service.port, db=keydb_service.db)
client.set("test_key", "test_value")
assert client.get("test_key") == b"test_value"

def test_dragonfly(dragonfly_service: RedisService) -> None:
client = redis.Redis(host=dragonfly_service.host, port=dragonfly_service.port, db=dragonfly_service.db)
client.set("test_key", "test_value")
assert client.get("test_key") == b"test_value"

Available Fixtures
------------------

* ``redis_port``: The port number for the Redis service.
* ``redis_host``: The host name for the Redis service.
* ``redis_image``: The Docker image to use for Redis.
* ``redis_service``: A fixture that provides a Redis service.
* ``redis_connection``: A fixture that provides a Redis connection.
* ``redis_service``: A fixture that provides a ``RedisService`` (``host``, ``port``, ``container``, ``db``).

The following version-specific fixtures are also available:
The following compatible-service fixtures are also available:

* ``dragonflydb_port``, ``dragonflydb_host``, ``dragonflydb_image``, ``dragonflydb_service``, ``dragonflydb_connection``: Latest Available DragonflyDB Docker image.
* ``keydb_port``, ``keydb_host``, ``keydb_image``, ``keydb_service``, ``keydb_connection``: Latest Available KeyDB Docker image.
* ``dragonfly_port``, ``dragonfly_host``, ``dragonfly_image``, ``dragonfly_service``: Latest available DragonflyDB Docker image.
* ``keydb_port``, ``keydb_host``, ``keydb_image``, ``keydb_service``: Latest available KeyDB Docker image.

Service API
-----------
Expand Down
7 changes: 3 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -62,18 +62,18 @@ Source = "https://github.com/litestar-org/pytest-databases"
azure-storage = ["azure-storage-blob"]
bigquery = ["google-cloud-bigquery"]
cockroachdb = []
dragonfly = ["redis"]
dragonfly = []
elasticsearch7 = []
elasticsearch8 = []
gizmosql = ["adbc-driver-flightsql", "pyarrow"]
keydb = ["redis"]
keydb = []
mariadb = []
mongodb = []
mssql = []
mysql = []
oracle = []
postgres = ["psycopg>=3"]
redis = ["redis"]
redis = []
spanner = []
valkey = []
yugabyte = []
Expand Down Expand Up @@ -115,7 +115,6 @@ lint = [
"types-decorator",
"types-pyyaml",
"types-docutils",
"types-redis",
"types-pymysql",
"slotscheck",
]
Expand Down
88 changes: 72 additions & 16 deletions src/pytest_databases/docker/redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,72 @@
from typing import TYPE_CHECKING

import pytest
from redis import Redis
from redis.exceptions import ConnectionError as RedisConnectionError
from docker.errors import ContainerError

from pytest_databases.helpers import get_xdist_worker_num
from pytest_databases.types import ServiceContainer, XdistIsolationLevel

if TYPE_CHECKING:
from collections.abc import Generator
from collections.abc import Generator, Iterator

from docker.models.containers import Container

from pytest_databases._service import DockerService


REDIS_PROBE_IMAGE = "redis:latest"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be the same image as the redis that's currently running. Otherwise it might be an unnecessary pull



def _output_to_bytes(output: bytes | str | Iterator[bytes]) -> bytes:
if isinstance(output, bytes):
return output
if isinstance(output, str):
return output.encode()
return b"".join(output)


def _exec_redis_cli(container: Container, *args: str, db: int = 0) -> tuple[int, bytes]:
result = container.exec_run([
"redis-cli",
"-h",
"localhost",
"-p",
"6379",
"-n",
str(db),
*args,
])
return result.exit_code if result.exit_code is not None else -1, _output_to_bytes(result.output)


def _probe_redis_endpoint(
docker_service: DockerService,
service: ServiceContainer,
*args: str,
db: int = 0,
probe_image: str = REDIS_PROBE_IMAGE,
) -> tuple[int, bytes]:
try:
output = docker_service._client.containers.run(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not use the already running container for this? We could modify check so you can pass in a function that takes the running Container as an argument

image=probe_image,
command=[
"redis-cli",
"-h",
service.host,
"-p",
str(service.port),
"-n",
str(db),
*args,
],
network_mode="host",
remove=True,
)
except ContainerError as exc:
return exc.exit_status, _output_to_bytes(exc.stderr) if exc.stderr else str(exc).encode()
return 0, _output_to_bytes(output)


@dataclasses.dataclass
class RedisService(ServiceContainer):
db: int
Expand All @@ -26,16 +80,6 @@ def xdist_redis_isolation_level() -> XdistIsolationLevel:
return "database"


def redis_responsive(service_container: ServiceContainer) -> bool:
client = Redis(host=service_container.host, port=service_container.port)
try:
return client.ping()
except (ConnectionError, RedisConnectionError):
return False
finally:
client.close()


@pytest.fixture(autouse=False, scope="session")
def redis_port(redis_service: RedisService) -> int:
return redis_service.port
Expand Down Expand Up @@ -66,9 +110,13 @@ def redis_service(
else:
name += f"_{worker_num + 1}"

def _responsive(_service: ServiceContainer) -> bool:
exit_code, output = _probe_redis_endpoint(docker_service, _service, "PING")
return exit_code == 0 and output.strip().endswith(b"PONG")

with docker_service.run(
redis_image,
check=redis_responsive,
check=_responsive,
container_port=6379,
name=name,
transient=xdist_redis_isolation_level == "server",
Expand Down Expand Up @@ -101,9 +149,13 @@ def dragonfly_service(
else:
name += f"_{worker_num + 1}"

def _responsive(_service: ServiceContainer) -> bool:
exit_code, output = _probe_redis_endpoint(docker_service, _service, "PING")
return exit_code == 0 and output.strip().endswith(b"PONG")

with docker_service.run(
dragonfly_image,
check=redis_responsive,
check=_responsive,
container_port=6379,
name=name,
transient=xdist_redis_isolation_level == "server",
Expand Down Expand Up @@ -146,9 +198,13 @@ def keydb_service(
else:
name += f"_{worker_num + 1}"

def _responsive(_service: ServiceContainer) -> bool:
exit_code, output = _probe_redis_endpoint(docker_service, _service, "PING")
return exit_code == 0 and output.strip().endswith(b"PONG")

with docker_service.run(
keydb_image,
check=redis_responsive,
check=_responsive,
container_port=6379,
name=name,
transient=xdist_redis_isolation_level == "server",
Expand Down
Loading