From a2c049fd77fa1b6cfb739b2b88584f901bd40552 Mon Sep 17 00:00:00 2001 From: Vasil Chomakov Date: Wed, 10 Dec 2025 19:41:03 +0200 Subject: [PATCH 1/4] Add DriverInfo class for upstream driver tracking --- redis/__init__.py | 2 + redis/asyncio/client.py | 9 ++- redis/client.py | 12 ++- redis/driver_info.py | 118 ++++++++++++++++++++++++++++ tests/test_asyncio/test_commands.py | 14 ++++ tests/test_commands.py | 11 +++ tests/test_driver_info.py | 50 ++++++++++++ 7 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 redis/driver_info.py create mode 100644 tests/test_driver_info.py diff --git a/redis/__init__.py b/redis/__init__.py index fd90163c30..57f6cfa4b7 100644 --- a/redis/__init__.py +++ b/redis/__init__.py @@ -1,6 +1,7 @@ from redis import asyncio # noqa from redis.backoff import default_backoff from redis.client import Redis, StrictRedis +from redis.driver_info import DriverInfo from redis.cluster import RedisCluster from redis.connection import ( BlockingConnectionPool, @@ -63,6 +64,7 @@ def int_or_str(value): "CredentialProvider", "CrossSlotTransactionError", "DataError", + "DriverInfo", "from_url", "default_backoff", "InvalidPipelineStack", diff --git a/redis/asyncio/client.py b/redis/asyncio/client.py index de2d7a0dd9..6b8e272d30 100644 --- a/redis/asyncio/client.py +++ b/redis/asyncio/client.py @@ -39,6 +39,7 @@ ) from redis.asyncio.lock import Lock from redis.asyncio.retry import Retry +from redis.driver_info import DriverInfo from redis.backoff import ExponentialWithJitterBackoff from redis.client import ( EMPTY_RESPONSE, @@ -252,6 +253,7 @@ def __init__( client_name: Optional[str] = None, lib_name: Optional[str] = "redis-py", lib_version: Optional[str] = get_lib_version(), + driver_info: Optional["DriverInfo"] = None, username: Optional[str] = None, auto_close_connection_pool: Optional[bool] = None, redis_connect_func=None, @@ -304,6 +306,11 @@ def __init__( # Create internal connection pool, expected to be closed by Redis instance if not retry_on_error: retry_on_error = [] + if driver_info is not None: + computed_lib_name = driver_info.formatted_name + else: + computed_lib_name = lib_name + kwargs = { "db": db, "username": username, @@ -318,7 +325,7 @@ def __init__( "max_connections": max_connections, "health_check_interval": health_check_interval, "client_name": client_name, - "lib_name": lib_name, + "lib_name": computed_lib_name, "lib_version": lib_version, "redis_connect_func": redis_connect_func, "protocol": protocol, diff --git a/redis/client.py b/redis/client.py index d3ab3cfcfe..caff332589 100755 --- a/redis/client.py +++ b/redis/client.py @@ -60,6 +60,7 @@ MaintNotificationsConfig, ) from redis.retry import Retry +from redis.driver_info import DriverInfo from redis.utils import ( _set_info_logger, deprecated_args, @@ -242,6 +243,7 @@ def __init__( client_name: Optional[str] = None, lib_name: Optional[str] = "redis-py", lib_version: Optional[str] = get_lib_version(), + driver_info: Optional["DriverInfo"] = None, username: Optional[str] = None, redis_connect_func: Optional[Callable[[], None]] = None, credential_provider: Optional[CredentialProvider] = None, @@ -280,6 +282,9 @@ def __init__( decode_responses: if `True`, the response will be decoded to utf-8. Argument is ignored when connection_pool is provided. + driver_info: + Optional DriverInfo object to identify upstream libraries. + Argument is ignored when connection_pool is provided. maint_notifications_config: configuration the pool to support maintenance notifications - see `redis.maint_notifications.MaintNotificationsConfig` for details. @@ -296,6 +301,11 @@ def __init__( if not connection_pool: if not retry_on_error: retry_on_error = [] + if driver_info is not None: + computed_lib_name = driver_info.formatted_name + else: + computed_lib_name = lib_name + kwargs = { "db": db, "username": username, @@ -309,7 +319,7 @@ def __init__( "max_connections": max_connections, "health_check_interval": health_check_interval, "client_name": client_name, - "lib_name": lib_name, + "lib_name": computed_lib_name, "lib_version": lib_version, "redis_connect_func": redis_connect_func, "credential_provider": credential_provider, diff --git a/redis/driver_info.py b/redis/driver_info.py new file mode 100644 index 0000000000..9eec8cca9c --- /dev/null +++ b/redis/driver_info.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import List + +_BRACES = {"(", ")", "[", "]", "{", "}"} + + +def _validate_no_invalid_chars(value: str, field_name: str) -> None: + """Ensure value contains only printable ASCII without spaces or braces. + + This mirrors the constraints enforced by other Redis clients for values that + will appear in CLIENT LIST / CLIENT INFO output. + """ + + for ch in value: + # printable ASCII without space: '!' (0x21) to '~' (0x7E) + if ord(ch) < 0x21 or ord(ch) > 0x7E or ch in _BRACES: + raise ValueError( + f"{field_name} must not contain spaces, newlines, non-printable characters, or braces" + ) + + +def _validate_driver_name(name: str) -> None: + """Validate an upstream driver name. + + The name should look like a typical Python distribution or package name, + following a simplified form of PEP 503 normalisation rules: + + * start with a lowercase ASCII letter + * contain only lowercase letters, digits, hyphens and underscores + + Examples of valid names: ``"django-redis"``, ``"celery"``, ``"rq"``. + """ + + import re + + _validate_no_invalid_chars(name, "Driver name") + if not re.match(r"^[a-z][a-z0-9_-]*$", name): + raise ValueError( + "Upstream driver name must use a Python package-style name: " + "start with a lowercase letter and contain only lowercase letters, " + "digits, hyphens, and underscores (e.g., 'django-redis')." + ) + + +def _validate_driver_version(version: str) -> None: + _validate_no_invalid_chars(version, "Driver version") + + +def _format_driver_entry(driver_name: str, driver_version: str) -> str: + return f"{driver_name}_v{driver_version}" + + +@dataclass +class DriverInfo: + """Driver information used to build the CLIENT SETINFO LIB-NAME value. + + The formatted name follows the pattern:: + + name(driver1_vVersion1;driver2_vVersion2) + + Examples + -------- + >>> info = DriverInfo() + >>> info.formatted_name + 'redis-py' + + >>> info = DriverInfo().add_upstream_driver("django-redis", "5.4.0") + >>> info.formatted_name + 'redis-py(django-redis_v5.4.0)' + """ + + name: str = "redis-py" + _upstream: List[str] = field(default_factory=list) + + @property + def upstream_drivers(self) -> List[str]: + """Return a copy of the upstream driver entries. + + Each entry is in the form ``"driver-name_vversion"``. + """ + + return list(self._upstream) + + def add_upstream_driver( + self, driver_name: str, driver_version: str + ) -> "DriverInfo": + """Add an upstream driver to this instance and return self. + + The most recently added driver appears first in :pyattr:`formatted_name`. + """ + + if driver_name is None: + raise ValueError("Driver name must not be None") + if driver_version is None: + raise ValueError("Driver version must not be None") + + _validate_driver_name(driver_name) + _validate_driver_version(driver_version) + + entry = _format_driver_entry(driver_name, driver_version) + # insert at the beginning so latest is first + self._upstream.insert(0, entry) + return self + + @property + def formatted_name(self) -> str: + """Return the base name with upstream drivers encoded, if any. + + With no upstream drivers, this is just :pyattr:`name`. Otherwise:: + + name(driver1_vX;driver2_vY) + """ + + if not self._upstream: + return self.name + return f"{self.name}({';'.join(self._upstream)})" diff --git a/tests/test_asyncio/test_commands.py b/tests/test_asyncio/test_commands.py index 47d8893743..ca87191319 100644 --- a/tests/test_asyncio/test_commands.py +++ b/tests/test_asyncio/test_commands.py @@ -541,6 +541,20 @@ async def test_client_setinfo(self, r: redis.Redis): info = await r2.client_info() assert info["lib-name"] == "test2" assert info["lib-ver"] == "1234" + + @skip_if_server_version_lt("7.2.0") + async def test_client_setinfo_with_driver_info(self, r: redis.Redis): + from redis import DriverInfo + + info = DriverInfo().add_upstream_driver("celery", "5.4.1") + r2 = redis.asyncio.Redis(driver_info=info) + await r2.ping() + client_info = await r2.client_info() + assert ( + client_info["lib-name"] + == "redis-py(celery_v5.4.1)" + ) + assert client_info["lib-ver"] == redis.__version__ await r2.aclose() r3 = redis.asyncio.Redis(lib_name=None, lib_version=None) info = await r3.client_info() diff --git a/tests/test_commands.py b/tests/test_commands.py index d7b56ca32f..c4b2f0d72c 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -744,6 +744,17 @@ def test_client_setinfo(self, r: redis.Redis): info = r2.client_info() assert info["lib-name"] == "test2" assert info["lib-ver"] == "1234" + + @skip_if_server_version_lt("7.2.0") + def test_client_setinfo_with_driver_info(self, r: redis.Redis): + from redis import DriverInfo + + info = DriverInfo().add_upstream_driver("django-redis", "5.4.0") + r2 = redis.Redis(driver_info=info) + r2.ping() + client_info = r2.client_info() + assert client_info["lib-name"] == "redis-py(django-redis_v5.4.0)" + assert client_info["lib-ver"] == redis.__version__ r3 = redis.Redis(lib_name=None, lib_version=None) info = r3.client_info() assert info["lib-name"] == "" diff --git a/tests/test_driver_info.py b/tests/test_driver_info.py new file mode 100644 index 0000000000..f6a566efdb --- /dev/null +++ b/tests/test_driver_info.py @@ -0,0 +1,50 @@ +import pytest + +from redis.driver_info import DriverInfo + + +def test_driver_info_default_name_no_upstream(): + info = DriverInfo() + assert info.formatted_name == "redis-py" + assert info.upstream_drivers == [] + + +def test_driver_info_single_upstream(): + info = DriverInfo().add_upstream_driver("django-redis", "5.4.0") + assert info.formatted_name == "redis-py(django-redis_v5.4.0)" + + +def test_driver_info_multiple_upstreams_latest_first(): + info = DriverInfo() + info.add_upstream_driver("django-redis", "5.4.0") + info.add_upstream_driver("celery", "5.4.1") + assert info.formatted_name == "redis-py(celery_v5.4.1;django-redis_v5.4.0)" + + +@pytest.mark.parametrize( + "name", + [ + "DjangoRedis", # must start with lowercase + "django redis", # spaces not allowed + "django{redis}", # braces not allowed + "django:redis", # ':' not allowed by validation regex + ], +) +def test_driver_info_invalid_name(name): + info = DriverInfo() + with pytest.raises(ValueError): + info.add_upstream_driver(name, "3.2.0") + + +@pytest.mark.parametrize( + "version", + [ + "3.2.0 beta", # space not allowed + "3.2.0)", # brace not allowed + "3.2.0\n", # newline not allowed + ], +) +def test_driver_info_invalid_version(version): + info = DriverInfo() + with pytest.raises(ValueError): + info.add_upstream_driver("django-redis", version) From 51d2f602bb306d6825616c04262d350876252e80 Mon Sep 17 00:00:00 2001 From: Vasil Chomakov Date: Wed, 10 Dec 2025 19:51:52 +0200 Subject: [PATCH 2/4] Fix linting issues - replace tabs with spaces and sort imports --- redis/__init__.py | 2 +- redis/asyncio/client.py | 2 +- redis/client.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/redis/__init__.py b/redis/__init__.py index 57f6cfa4b7..89dee024d2 100644 --- a/redis/__init__.py +++ b/redis/__init__.py @@ -64,7 +64,7 @@ def int_or_str(value): "CredentialProvider", "CrossSlotTransactionError", "DataError", - "DriverInfo", + "DriverInfo", "from_url", "default_backoff", "InvalidPipelineStack", diff --git a/redis/asyncio/client.py b/redis/asyncio/client.py index 6b8e272d30..46881abf3e 100644 --- a/redis/asyncio/client.py +++ b/redis/asyncio/client.py @@ -39,7 +39,6 @@ ) from redis.asyncio.lock import Lock from redis.asyncio.retry import Retry -from redis.driver_info import DriverInfo from redis.backoff import ExponentialWithJitterBackoff from redis.client import ( EMPTY_RESPONSE, @@ -54,6 +53,7 @@ list_or_args, ) from redis.credentials import CredentialProvider +from redis.driver_info import DriverInfo from redis.event import ( AfterPooledConnectionsInstantiationEvent, AfterPubSubConnectionInstantiationEvent, diff --git a/redis/client.py b/redis/client.py index caff332589..37e3a67e31 100755 --- a/redis/client.py +++ b/redis/client.py @@ -40,6 +40,7 @@ UnixDomainSocketConnection, ) from redis.credentials import CredentialProvider +from redis.driver_info import DriverInfo from redis.event import ( AfterPooledConnectionsInstantiationEvent, AfterPubSubConnectionInstantiationEvent, @@ -60,7 +61,6 @@ MaintNotificationsConfig, ) from redis.retry import Retry -from redis.driver_info import DriverInfo from redis.utils import ( _set_info_logger, deprecated_args, From de5957c5aaaa8d5ab2ecfdd9c2fb7dfb5ff17550 Mon Sep 17 00:00:00 2001 From: Vasil Chomakov Date: Wed, 10 Dec 2025 19:54:11 +0200 Subject: [PATCH 3/4] Fix code formatting in test_asyncio/test_commands.py --- tests/test_asyncio/test_commands.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/test_asyncio/test_commands.py b/tests/test_asyncio/test_commands.py index ca87191319..7cdb7ca751 100644 --- a/tests/test_asyncio/test_commands.py +++ b/tests/test_asyncio/test_commands.py @@ -550,10 +550,7 @@ async def test_client_setinfo_with_driver_info(self, r: redis.Redis): r2 = redis.asyncio.Redis(driver_info=info) await r2.ping() client_info = await r2.client_info() - assert ( - client_info["lib-name"] - == "redis-py(celery_v5.4.1)" - ) + assert client_info["lib-name"] == "redis-py(celery_v5.4.1)" assert client_info["lib-ver"] == redis.__version__ await r2.aclose() r3 = redis.asyncio.Redis(lib_name=None, lib_version=None) From f2df255f2fbdcb59b01a76669db5612fbf78e4d0 Mon Sep 17 00:00:00 2001 From: Vasil Chomakov Date: Tue, 16 Dec 2025 18:58:46 +0200 Subject: [PATCH 4/4] Implement DriverInfo pool propagation and deprecate lib_name/lib_version --- redis/asyncio/client.py | 22 ++++++++--- redis/asyncio/connection.py | 60 ++++++++++++++++++++++++----- redis/client.py | 28 +++++++++++--- redis/connection.py | 49 ++++++++++++++++++----- redis/driver_info.py | 27 ++++++++++++- tests/test_asyncio/test_commands.py | 18 +++++---- tests/test_commands.py | 16 ++++---- tests/test_driver_info.py | 8 ++++ 8 files changed, 180 insertions(+), 48 deletions(-) diff --git a/redis/asyncio/client.py b/redis/asyncio/client.py index 46881abf3e..74b548ded7 100644 --- a/redis/asyncio/client.py +++ b/redis/asyncio/client.py @@ -215,6 +215,11 @@ def from_pool( reason="TimeoutError is included by default.", version="6.0.0", ) + @deprecated_args( + args_to_warn=["lib_name", "lib_version"], + reason="Use 'driver_info' parameter instead. " + "lib_name and lib_version will be removed in a future version.", + ) def __init__( self, *, @@ -251,8 +256,8 @@ def __init__( single_connection_client: bool = False, health_check_interval: int = 0, client_name: Optional[str] = None, - lib_name: Optional[str] = "redis-py", - lib_version: Optional[str] = get_lib_version(), + lib_name: Optional[str] = None, + lib_version: Optional[str] = None, driver_info: Optional["DriverInfo"] = None, username: Optional[str] = None, auto_close_connection_pool: Optional[bool] = None, @@ -306,10 +311,16 @@ def __init__( # Create internal connection pool, expected to be closed by Redis instance if not retry_on_error: retry_on_error = [] + + # Handle driver_info: if provided, use it; otherwise create from lib_name/lib_version if driver_info is not None: - computed_lib_name = driver_info.formatted_name + computed_driver_info = driver_info else: - computed_lib_name = lib_name + # Fallback: create DriverInfo from lib_name and lib_version + # Use defaults if not provided + name = lib_name if lib_name is not None else "redis-py" + version = lib_version if lib_version is not None else get_lib_version() + computed_driver_info = DriverInfo(name=name, lib_version=version) kwargs = { "db": db, @@ -325,8 +336,7 @@ def __init__( "max_connections": max_connections, "health_check_interval": health_check_interval, "client_name": client_name, - "lib_name": computed_lib_name, - "lib_version": lib_version, + "driver_info": computed_driver_info, "redis_connect_func": redis_connect_func, "protocol": protocol, } diff --git a/redis/asyncio/connection.py b/redis/asyncio/connection.py index 1d50b53ee2..d652af6e12 100644 --- a/redis/asyncio/connection.py +++ b/redis/asyncio/connection.py @@ -38,6 +38,7 @@ VerifyFlags = None from ..auth.token import TokenInterface +from ..driver_info import DriverInfo from ..event import AsyncAfterConnectionReleasedEvent, EventDispatcher from ..utils import deprecated_args, format_error_message @@ -137,6 +138,11 @@ class AbstractConnection: "__dict__", ) + @deprecated_args( + args_to_warn=["lib_name", "lib_version"], + reason="Use 'driver_info' parameter instead. " + "lib_name and lib_version will be removed in a future version.", + ) def __init__( self, *, @@ -153,8 +159,9 @@ def __init__( socket_read_size: int = 65536, health_check_interval: float = 0, client_name: Optional[str] = None, - lib_name: Optional[str] = "redis-py", - lib_version: Optional[str] = get_lib_version(), + lib_name: Optional[str] = None, + lib_version: Optional[str] = None, + driver_info: Optional[DriverInfo] = None, username: Optional[str] = None, retry: Optional[Retry] = None, redis_connect_func: Optional[ConnectCallbackT] = None, @@ -163,6 +170,20 @@ def __init__( protocol: Optional[int] = 2, event_dispatcher: Optional[EventDispatcher] = None, ): + """ + Initialize a new async Connection. + + Parameters + ---------- + driver_info : DriverInfo, optional + Driver metadata for CLIENT SETINFO. If provided, lib_name and lib_version + are ignored. If not provided, a DriverInfo will be created from lib_name + and lib_version (or defaults if those are also None). + lib_name : str, optional + **Deprecated.** Use driver_info instead. Library name for CLIENT SETINFO. + lib_version : str, optional + **Deprecated.** Use driver_info instead. Library version for CLIENT SETINFO. + """ if (username or password) and credential_provider is not None: raise DataError( "'username' and 'password' cannot be passed along with 'credential_" @@ -176,8 +197,17 @@ def __init__( self._event_dispatcher = event_dispatcher self.db = db self.client_name = client_name - self.lib_name = lib_name - self.lib_version = lib_version + + # Handle driver_info: if provided, use it; otherwise create from lib_name/lib_version + if driver_info is not None: + self.driver_info = driver_info + else: + # Fallback: create DriverInfo from lib_name and lib_version + # Use defaults if not provided + name = lib_name if lib_name is not None else "redis-py" + version = lib_version if lib_version is not None else get_lib_version() + self.driver_info = DriverInfo(name=name, lib_version=version) + self.credential_provider = credential_provider self.password = password self.username = username @@ -452,29 +482,36 @@ async def on_connect_check_health(self, check_health: bool = True) -> None: if str_if_bytes(await self.read_response()) != "OK": raise ConnectionError("Error setting client name") - # set the library name and version, pipeline for lower startup latency - if self.lib_name: + # Set the library name and version from driver_info, pipeline for lower startup latency + lib_name_sent = False + lib_version_sent = False + + if self.driver_info and self.driver_info.formatted_name: await self.send_command( "CLIENT", "SETINFO", "LIB-NAME", - self.lib_name, + self.driver_info.formatted_name, check_health=check_health, ) - if self.lib_version: + lib_name_sent = True + + if self.driver_info and self.driver_info.lib_version: await self.send_command( "CLIENT", "SETINFO", "LIB-VER", - self.lib_version, + self.driver_info.lib_version, check_health=check_health, ) + lib_version_sent = True + # if a database is specified, switch to it. Also pipeline this if self.db: await self.send_command("SELECT", self.db, check_health=check_health) # read responses from pipeline - for _ in (sent for sent in (self.lib_name, self.lib_version) if sent): + for _ in range(sum([lib_name_sent, lib_version_sent])): try: await self.read_response() except ResponseError: @@ -1174,6 +1211,9 @@ def __init__( if self._event_dispatcher is None: self._event_dispatcher = EventDispatcher() + # Store driver_info for propagation to connections + self.driver_info = self.connection_kwargs.get("driver_info", None) + def __repr__(self): conn_kwargs = ",".join([f"{k}={v}" for k, v in self.connection_kwargs.items()]) return ( diff --git a/redis/client.py b/redis/client.py index 37e3a67e31..645f445b6e 100755 --- a/redis/client.py +++ b/redis/client.py @@ -200,6 +200,11 @@ def from_pool( reason="TimeoutError is included by default.", version="6.0.0", ) + @deprecated_args( + args_to_warn=["lib_name", "lib_version"], + reason="Use 'driver_info' parameter instead. " + "lib_name and lib_version will be removed in a future version.", + ) def __init__( self, host: str = "localhost", @@ -241,8 +246,8 @@ def __init__( single_connection_client: bool = False, health_check_interval: int = 0, client_name: Optional[str] = None, - lib_name: Optional[str] = "redis-py", - lib_version: Optional[str] = get_lib_version(), + lib_name: Optional[str] = None, + lib_version: Optional[str] = None, driver_info: Optional["DriverInfo"] = None, username: Optional[str] = None, redis_connect_func: Optional[Callable[[], None]] = None, @@ -284,7 +289,13 @@ def __init__( Argument is ignored when connection_pool is provided. driver_info: Optional DriverInfo object to identify upstream libraries. + If provided, lib_name and lib_version are ignored. + If not provided, a DriverInfo will be created from lib_name and lib_version. Argument is ignored when connection_pool is provided. + lib_name: + **Deprecated.** Use driver_info instead. Library name for CLIENT SETINFO. + lib_version: + **Deprecated.** Use driver_info instead. Library version for CLIENT SETINFO. maint_notifications_config: configuration the pool to support maintenance notifications - see `redis.maint_notifications.MaintNotificationsConfig` for details. @@ -301,10 +312,16 @@ def __init__( if not connection_pool: if not retry_on_error: retry_on_error = [] + + # Handle driver_info: if provided, use it; otherwise create from lib_name/lib_version if driver_info is not None: - computed_lib_name = driver_info.formatted_name + computed_driver_info = driver_info else: - computed_lib_name = lib_name + # Fallback: create DriverInfo from lib_name and lib_version + # Use defaults if not provided + name = lib_name if lib_name is not None else "redis-py" + version = lib_version if lib_version is not None else get_lib_version() + computed_driver_info = DriverInfo(name=name, lib_version=version) kwargs = { "db": db, @@ -319,8 +336,7 @@ def __init__( "max_connections": max_connections, "health_check_interval": health_check_interval, "client_name": client_name, - "lib_name": computed_lib_name, - "lib_version": lib_version, + "driver_info": computed_driver_info, "redis_connect_func": redis_connect_func, "credential_provider": credential_provider, "protocol": protocol, diff --git a/redis/connection.py b/redis/connection.py index fe25a9e3f4..a2c63212be 100644 --- a/redis/connection.py +++ b/redis/connection.py @@ -35,6 +35,7 @@ from .auth.token import TokenInterface from .backoff import NoBackoff from .credentials import CredentialProvider, UsernamePasswordCredentialProvider +from .driver_info import DriverInfo from .event import AfterConnectionReleasedEvent, EventDispatcher from .exceptions import ( AuthenticationError, @@ -655,6 +656,11 @@ def reset_tmp_settings( class AbstractConnection(MaintNotificationsAbstractConnection, ConnectionInterface): "Manages communication to and from a Redis server" + @deprecated_args( + args_to_warn=["lib_name", "lib_version"], + reason="Use 'driver_info' parameter instead. " + "lib_name and lib_version will be removed in a future version.", + ) def __init__( self, db: int = 0, @@ -670,8 +676,9 @@ def __init__( socket_read_size: int = 65536, health_check_interval: int = 0, client_name: Optional[str] = None, - lib_name: Optional[str] = "redis-py", - lib_version: Optional[str] = get_lib_version(), + lib_name: Optional[str] = None, + lib_version: Optional[str] = None, + driver_info: Optional[DriverInfo] = None, username: Optional[str] = None, retry: Union[Any, None] = None, redis_connect_func: Optional[Callable[[], None]] = None, @@ -691,10 +698,22 @@ def __init__( ): """ Initialize a new Connection. + To specify a retry policy for specific errors, first set `retry_on_error` to a list of the error/s to retry on, then set `retry` to a valid `Retry` object. To retry on TimeoutError, `retry_on_timeout` can also be set to `True`. + + Parameters + ---------- + driver_info : DriverInfo, optional + Driver metadata for CLIENT SETINFO. If provided, lib_name and lib_version + are ignored. If not provided, a DriverInfo will be created from lib_name + and lib_version (or defaults if those are also None). + lib_name : str, optional + **Deprecated.** Use driver_info instead. Library name for CLIENT SETINFO. + lib_version : str, optional + **Deprecated.** Use driver_info instead. Library version for CLIENT SETINFO. """ if (username or password) and credential_provider is not None: raise DataError( @@ -710,8 +729,17 @@ def __init__( self.pid = os.getpid() self.db = db self.client_name = client_name - self.lib_name = lib_name - self.lib_version = lib_version + + # Handle driver_info: if provided, use it; otherwise create from lib_name/lib_version + if driver_info is not None: + self.driver_info = driver_info + else: + # Fallback: create DriverInfo from lib_name and lib_version + # Use defaults if not provided + name = lib_name if lib_name is not None else "redis-py" + version = lib_version if lib_version is not None else get_lib_version() + self.driver_info = DriverInfo(name=name, lib_version=version) + self.credential_provider = credential_provider self.password = password self.username = username @@ -988,14 +1016,14 @@ def on_connect_check_health(self, check_health: bool = True): if str_if_bytes(self.read_response()) != "OK": raise ConnectionError("Error setting client name") + # Set the library name and version from driver_info try: - # set the library name and version - if self.lib_name: + if self.driver_info and self.driver_info.formatted_name: self.send_command( "CLIENT", "SETINFO", "LIB-NAME", - self.lib_name, + self.driver_info.formatted_name, check_health=check_health, ) self.read_response() @@ -1003,12 +1031,12 @@ def on_connect_check_health(self, check_health: bool = True): pass try: - if self.lib_version: + if self.driver_info and self.driver_info.lib_version: self.send_command( "CLIENT", "SETINFO", "LIB-VER", - self.lib_version, + self.driver_info.lib_version, check_health=check_health, ) self.read_response() @@ -2482,6 +2510,9 @@ def __init__( if self._event_dispatcher is None: self._event_dispatcher = EventDispatcher() + # Store driver_info for propagation to connections + self.driver_info = self._connection_kwargs.get("driver_info", None) + # a lock to protect the critical section in _checkpid(). # this lock is acquired when the process id changes, such as # after a fork. during this time, multiple threads in the child diff --git a/redis/driver_info.py b/redis/driver_info.py index 9eec8cca9c..892d851313 100644 --- a/redis/driver_info.py +++ b/redis/driver_info.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import List +from typing import List, Optional _BRACES = {"(", ")", "[", "]", "{", "}"} @@ -54,12 +54,23 @@ def _format_driver_entry(driver_name: str, driver_version: str) -> str: @dataclass class DriverInfo: - """Driver information used to build the CLIENT SETINFO LIB-NAME value. + """Driver information used to build the CLIENT SETINFO LIB-NAME and LIB-VER values. + + This class consolidates all driver metadata (redis-py version and upstream drivers) + into a single object that is propagated through connection pools and connections. The formatted name follows the pattern:: name(driver1_vVersion1;driver2_vVersion2) + Parameters + ---------- + name : str, optional + The base library name (default: "redis-py") + lib_version : str, optional + The redis-py library version. If None, the version will be determined + automatically from the installed package. + Examples -------- >>> info = DriverInfo() @@ -69,11 +80,23 @@ class DriverInfo: >>> info = DriverInfo().add_upstream_driver("django-redis", "5.4.0") >>> info.formatted_name 'redis-py(django-redis_v5.4.0)' + + >>> info = DriverInfo(lib_version="5.0.0") + >>> info.lib_version + '5.0.0' """ name: str = "redis-py" + lib_version: Optional[str] = None _upstream: List[str] = field(default_factory=list) + def __post_init__(self): + """Initialize lib_version if not provided.""" + if self.lib_version is None: + from redis.utils import get_lib_version + + self.lib_version = get_lib_version() + @property def upstream_drivers(self) -> List[str]: """Return a copy of the upstream driver entries. diff --git a/tests/test_asyncio/test_commands.py b/tests/test_asyncio/test_commands.py index 7cdb7ca751..34a6017d22 100644 --- a/tests/test_asyncio/test_commands.py +++ b/tests/test_asyncio/test_commands.py @@ -528,36 +528,38 @@ async def test_client_setname(self, r: redis.Redis): @skip_if_server_version_lt("7.2.0") async def test_client_setinfo(self, r: redis.Redis): + from redis.utils import get_lib_version + await r.ping() info = await r.client_info() assert info["lib-name"] == "redis-py" - assert info["lib-ver"] == redis.__version__ + assert info["lib-ver"] == get_lib_version() assert await r.client_setinfo("lib-name", "test") assert await r.client_setinfo("lib-ver", "123") info = await r.client_info() assert info["lib-name"] == "test" assert info["lib-ver"] == "123" - r2 = redis.asyncio.Redis(lib_name="test2", lib_version="1234") + + # Test deprecated lib_name/lib_version parameters + with pytest.warns(DeprecationWarning): + r2 = redis.asyncio.Redis(lib_name="test2", lib_version="1234") info = await r2.client_info() assert info["lib-name"] == "test2" assert info["lib-ver"] == "1234" + await r2.aclose() @skip_if_server_version_lt("7.2.0") async def test_client_setinfo_with_driver_info(self, r: redis.Redis): from redis import DriverInfo + from redis.utils import get_lib_version info = DriverInfo().add_upstream_driver("celery", "5.4.1") r2 = redis.asyncio.Redis(driver_info=info) await r2.ping() client_info = await r2.client_info() assert client_info["lib-name"] == "redis-py(celery_v5.4.1)" - assert client_info["lib-ver"] == redis.__version__ + assert client_info["lib-ver"] == get_lib_version() await r2.aclose() - r3 = redis.asyncio.Redis(lib_name=None, lib_version=None) - info = await r3.client_info() - assert info["lib-name"] == "" - assert info["lib-ver"] == "" - await r3.aclose() @skip_if_server_version_lt("2.6.9") @pytest.mark.onlynoncluster diff --git a/tests/test_commands.py b/tests/test_commands.py index c4b2f0d72c..4efc26f5c9 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -731,16 +731,21 @@ def test_client_setname(self, r): @skip_if_server_version_lt("7.2.0") def test_client_setinfo(self, r: redis.Redis): + from redis.utils import get_lib_version + r.ping() info = r.client_info() assert info["lib-name"] == "redis-py" - assert info["lib-ver"] == redis.__version__ + assert info["lib-ver"] == get_lib_version() assert r.client_setinfo("lib-name", "test") assert r.client_setinfo("lib-ver", "123") info = r.client_info() assert info["lib-name"] == "test" assert info["lib-ver"] == "123" - r2 = redis.Redis(lib_name="test2", lib_version="1234") + + # Test deprecated lib_name/lib_version parameters + with pytest.warns(DeprecationWarning): + r2 = redis.Redis(lib_name="test2", lib_version="1234") info = r2.client_info() assert info["lib-name"] == "test2" assert info["lib-ver"] == "1234" @@ -748,17 +753,14 @@ def test_client_setinfo(self, r: redis.Redis): @skip_if_server_version_lt("7.2.0") def test_client_setinfo_with_driver_info(self, r: redis.Redis): from redis import DriverInfo + from redis.utils import get_lib_version info = DriverInfo().add_upstream_driver("django-redis", "5.4.0") r2 = redis.Redis(driver_info=info) r2.ping() client_info = r2.client_info() assert client_info["lib-name"] == "redis-py(django-redis_v5.4.0)" - assert client_info["lib-ver"] == redis.__version__ - r3 = redis.Redis(lib_name=None, lib_version=None) - info = r3.client_info() - assert info["lib-name"] == "" - assert info["lib-ver"] == "" + assert client_info["lib-ver"] == get_lib_version() @pytest.mark.onlynoncluster @skip_if_server_version_lt("2.6.9") diff --git a/tests/test_driver_info.py b/tests/test_driver_info.py index f6a566efdb..eab2888360 100644 --- a/tests/test_driver_info.py +++ b/tests/test_driver_info.py @@ -1,12 +1,20 @@ import pytest from redis.driver_info import DriverInfo +from redis.utils import get_lib_version def test_driver_info_default_name_no_upstream(): info = DriverInfo() assert info.formatted_name == "redis-py" assert info.upstream_drivers == [] + assert info.lib_version == get_lib_version() + + +def test_driver_info_custom_lib_version(): + info = DriverInfo(lib_version="5.0.0") + assert info.lib_version == "5.0.0" + assert info.formatted_name == "redis-py" def test_driver_info_single_upstream():