From 3b669c8828a510b882efa7fa7c83bebd73dae250 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 24 May 2026 16:13:55 +0000 Subject: [PATCH 1/3] feat: move Valkey to clientless validation --- docs/supported-databases/valkey.rst | 15 ++--- pyproject.toml | 2 +- src/pytest_databases/docker/valkey.py | 46 +++++++++----- tests/test_valkey.py | 90 +++++++++++++++++++-------- uv.lock | 18 +----- 5 files changed, 102 insertions(+), 69 deletions(-) diff --git a/docs/supported-databases/valkey.rst b/docs/supported-databases/valkey.rst index 37a0abc..639683a 100644 --- a/docs/supported-databases/valkey.rst +++ b/docs/supported-databases/valkey.rst @@ -1,14 +1,16 @@ Valkey ====== -Integration with `Valkey `_ using the `Valkey Docker Image `_ +Integration with `Valkey `_ using the `Valkey Docker Image `_. Installation ------------ .. code-block:: bash - pip install pytest-databases[valkey] + pip install pytest-databases[valkey] valkey + +The ``valkey`` Python client is no longer pulled by ``pytest-databases[valkey]`` — the fixture validates the container via the bundled ``valkey-cli`` invoked through ``container.exec_run`` — so install your own client alongside ``pytest-databases``. Usage Example ------------- @@ -25,23 +27,18 @@ Usage Example client = Valkey( host=valkey_service.host, port=valkey_service.port, - db=valkey_service.db + db=valkey_service.db, ) client.set("test_key", "test_value") assert client.get("test_key") == b"test_value" - def test(valkey_connection: Valkey) -> None: - valkey_connection.set("test_key", "test_value") - assert valkey_connection.get("test_key") == b"test_value" - Available Fixtures ------------------ * ``valkey_port``: The port number for the Valkey service. * ``valkey_host``: The host name for the Valkey service. * ``valkey_image``: The Docker image to use for Valkey. -* ``valkey_service``: A fixture that provides a Valkey service. -* ``valkey_connection``: A fixture that provides a Valkey connection. +* ``valkey_service``: A fixture that provides a ``ValkeyService`` (``host``, ``port``, ``container``, ``db``). Service API ----------- diff --git a/pyproject.toml b/pyproject.toml index af70e1f..ca6e8b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,7 +75,7 @@ oracle = ["oracledb"] postgres = ["psycopg>=3"] redis = ["redis"] spanner = ["google-cloud-spanner"] -valkey = ["valkey"] +valkey = [] yugabyte = [] [dependency-groups] diff --git a/src/pytest_databases/docker/valkey.py b/src/pytest_databases/docker/valkey.py index 2604fbc..53f6bb3 100644 --- a/src/pytest_databases/docker/valkey.py +++ b/src/pytest_databases/docker/valkey.py @@ -1,21 +1,43 @@ from __future__ import annotations import dataclasses -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING import pytest -from valkey import Valkey -from valkey.exceptions import ConnectionError as ValkeyConnectionError 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 +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_valkey_cli(container: Container, *args: str, db: int = 0) -> tuple[int, bytes]: + result = container.exec_run([ + "valkey-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) + + @dataclasses.dataclass class ValkeyService(ServiceContainer): db: int @@ -26,16 +48,6 @@ def xdist_valkey_isolation_level() -> XdistIsolationLevel: return "database" -def valkey_responsive(service_container: ServiceContainer) -> bool: - client = Valkey(host=service_container.host, port=service_container.port) - try: - return cast("bool", client.ping()) - except (ConnectionError, ValkeyConnectionError): - return False - finally: - client.close() - - @pytest.fixture(autouse=False, scope="session") def valkey_port(valkey_service: ValkeyService) -> int: return valkey_service.port @@ -68,9 +80,13 @@ def valkey_service( else: name += f"_{worker_num + 1}" + def _responsive(_service: ServiceContainer) -> bool: + exit_code, output = _exec_valkey_cli(_service.container, "PING") + return exit_code == 0 and output.strip().endswith(b"PONG") + with docker_service.run( valkey_image, - check=valkey_responsive, + check=_responsive, container_port=6379, name=name, transient=xdist_valkey_isolation_level == "server", diff --git a/tests/test_valkey.py b/tests/test_valkey.py index 81d43f7..ce4d892 100644 --- a/tests/test_valkey.py +++ b/tests/test_valkey.py @@ -3,6 +3,53 @@ import pytest +def test_plugin_imports_without_valkey(pytester: pytest.Pytester) -> None: + pytester.makepyfile(""" + import builtins + + def test_import() -> None: + original_import = builtins.__import__ + + def blocked_import(name, globals=None, locals=None, fromlist=(), level=0): + if name == "valkey" or name.startswith("valkey."): + raise ModuleNotFoundError(name) + return original_import(name, globals, locals, fromlist, level) + + builtins.__import__ = blocked_import + try: + import pytest_databases.docker.valkey + finally: + builtins.__import__ = original_import + """) + + result = pytester.runpytest_subprocess("-p", "pytest_databases", "-vv") + result.assert_outcomes(passed=1) + + +VALKEY_TEST_HELPERS = """ +def run_valkey(service, *args, db=None): + if db is None: + db = service.db + result = service.container.exec_run([ + "valkey-cli", + "-h", + "localhost", + "-p", + "6379", + "-n", + str(db), + *args, + ]) + assert result.exit_code == 0, result.output.decode(errors="replace") + output = result.output + if isinstance(output, bytes): + return output.decode().strip() + if isinstance(output, str): + return output.strip() + return b"".join(output).decode().strip() +""" + + @pytest.fixture(params=[pytest.param("valkey_service", id="valkey")]) def valkey_compatible_service(request: pytest.FixtureRequest) -> str: return request.param @@ -10,17 +57,14 @@ def valkey_compatible_service(request: pytest.FixtureRequest) -> str: def test_default_no_xdist(pytester: pytest.Pytester, valkey_compatible_service: str) -> None: pytester.makepyfile(f""" -import pytest -import valkey from pytest_databases.docker.valkey import ValkeyService -from pytest_databases.helpers import get_xdist_worker_num -pytest_plugins = [ - "pytest_databases.docker.valkey", -] +pytest_plugins = ["pytest_databases.docker.valkey"] + +{VALKEY_TEST_HELPERS} def test_valkey_service({valkey_compatible_service}: ValkeyService) -> None: - assert valkey.Valkey.from_url("valkey://", host={valkey_compatible_service}.host, port={valkey_compatible_service}.port).ping() + assert run_valkey({valkey_compatible_service}, "PING") == "PONG" """) result = pytester.runpytest_subprocess("-p", "pytest_databases") result.assert_outcomes(passed=1) @@ -28,25 +72,21 @@ def test_valkey_service({valkey_compatible_service}: ValkeyService) -> None: def test_xdist_isolate_database(pytester: pytest.Pytester, valkey_compatible_service: str) -> None: pytester.makepyfile(f""" -import pytest -import valkey from pytest_databases.docker.valkey import ValkeyService from pytest_databases.helpers import get_xdist_worker_num -pytest_plugins = [ - "pytest_databases.docker.valkey", -] +pytest_plugins = ["pytest_databases.docker.valkey"] + +{VALKEY_TEST_HELPERS} def test_one({valkey_compatible_service}: ValkeyService) -> None: - client = valkey.Valkey.from_url("valkey://", host={valkey_compatible_service}.host, port={valkey_compatible_service}.port) - assert client.ping() + assert run_valkey({valkey_compatible_service}, "PING") == "PONG" assert {valkey_compatible_service}.db == get_xdist_worker_num() def test_two({valkey_compatible_service}: ValkeyService) -> None: - client = valkey.Valkey.from_url("valkey://", host={valkey_compatible_service}.host, port={valkey_compatible_service}.port) - assert not client.get("one") - client.set("one", "1") + assert run_valkey({valkey_compatible_service}, "GET", "one") in ("", "(nil)") + assert run_valkey({valkey_compatible_service}, "SET", "one", "1") == "OK" assert {valkey_compatible_service}.db == get_xdist_worker_num() """) result = pytester.runpytest_subprocess("-p", "pytest_databases", "-n", "2") @@ -56,29 +96,25 @@ def test_two({valkey_compatible_service}: ValkeyService) -> None: def test_xdist_isolate_server(pytester: pytest.Pytester, valkey_compatible_service: str) -> None: pytester.makepyfile(f""" import pytest -import valkey from pytest_databases.docker.valkey import ValkeyService -from pytest_databases.helpers import get_xdist_worker_num -pytest_plugins = [ - "pytest_databases.docker.valkey", -] +pytest_plugins = ["pytest_databases.docker.valkey"] + @pytest.fixture(scope="session") def xdist_valkey_isolation_level(): return "server" +{VALKEY_TEST_HELPERS} def test_one({valkey_compatible_service}: ValkeyService) -> None: - client = valkey.Valkey.from_url("valkey://", host={valkey_compatible_service}.host, port={valkey_compatible_service}.port) - assert client.ping() + assert run_valkey({valkey_compatible_service}, "PING") == "PONG" assert {valkey_compatible_service}.db == 0 def test_two({valkey_compatible_service}: ValkeyService) -> None: - client = valkey.Valkey.from_url("valkey://", host={valkey_compatible_service}.host, port={valkey_compatible_service}.port) - assert not client.get("one") - client.set("one", "1") + assert run_valkey({valkey_compatible_service}, "GET", "one") in ("", "(nil)") + assert run_valkey({valkey_compatible_service}, "SET", "one", "1") == "OK" assert {valkey_compatible_service}.db == 0 """) result = pytester.runpytest_subprocess("-p", "pytest_databases", "-n", "2") diff --git a/uv.lock b/uv.lock index 7c7eeef..2c48e7a 100644 --- a/uv.lock +++ b/uv.lock @@ -1345,7 +1345,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -4026,9 +4026,6 @@ redis = [ spanner = [ { name = "google-cloud-spanner" }, ] -valkey = [ - { name = "valkey" }, -] [package.dev-dependencies] build = [ @@ -4183,7 +4180,6 @@ requires-dist = [ { name = "redis", marker = "extra == 'dragonfly'" }, { name = "redis", marker = "extra == 'keydb'" }, { name = "redis", marker = "extra == 'redis'" }, - { name = "valkey", marker = "extra == 'valkey'" }, ] provides-extras = ["azure-storage", "bigquery", "cockroachdb", "dragonfly", "elasticsearch7", "elasticsearch8", "gizmosql", "keydb", "mariadb", "mongodb", "mssql", "mysql", "oracle", "postgres", "redis", "spanner", "valkey", "yugabyte"] @@ -6032,18 +6028,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/41/ac2dfdbc1f60c7af4f994c7a335cfa7040c01642b605d65f611cecc2a1e4/uvicorn-0.47.0-py3-none-any.whl", hash = "sha256:2c5715bc12d1892d84752049f400cd1c3cb018514967fdfeb97640443a6a9432", size = 71301, upload-time = "2026-05-14T18:16:51.762Z" }, ] -[[package]] -name = "valkey" -version = "6.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c3/ee/7fd930fc712275084722ddd464a0ea296abdb997d2da396320507968daeb/valkey-6.1.1.tar.gz", hash = "sha256:5880792990c6c2b5eb604a5ed5f98f300880b6dd92d123819b66ed54bb259731", size = 4601372, upload-time = "2025-08-11T06:41:10.63Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/a2/252afa4da08c714460f49e943070f86a02931f99f886182765194002fe33/valkey-6.1.1-py3-none-any.whl", hash = "sha256:e2691541c6e1503b53c714ad9a35551ac9b7c0bbac93865f063dbc859a46de92", size = 259474, upload-time = "2025-08-11T06:41:08.769Z" }, -] - [[package]] name = "virtualenv" version = "21.3.3" From 9eec13ca8b347b6a7572ecde9be1677ba93a76a9 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 24 May 2026 15:09:31 +0000 Subject: [PATCH 2/3] fix(ci): keep cdist shards populated --- pyproject.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ca6e8b6..232d868 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -385,7 +385,9 @@ line-length = 120 [tool.pytest.ini_options] addopts = "--doctest-glob='*.md' --dist=loadgroup" -cdist-group-steal = "3:10" +# Keep the middle shard non-empty when file justification moves a large test file +# across cdist chunk boundaries. +cdist-group-steal = "2:10,3:10" cdist-justify-items = "file" filterwarnings = [ "ignore::DeprecationWarning:pkg_resources", From 619ec86f657519bddc62244cf187542e27da5ad9 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 24 May 2026 15:17:06 +0000 Subject: [PATCH 3/3] fix(ci): use python 3.9 compatible cdist config --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 232d868..31e33f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -386,8 +386,8 @@ line-length = 120 [tool.pytest.ini_options] addopts = "--doctest-glob='*.md' --dist=loadgroup" # Keep the middle shard non-empty when file justification moves a large test file -# across cdist chunk boundaries. -cdist-group-steal = "2:10,3:10" +# across cdist chunk boundaries. pytest-cdist 0.3.x accepts only one steal entry. +cdist-group-steal = "2:10" cdist-justify-items = "file" filterwarnings = [ "ignore::DeprecationWarning:pkg_resources",