diff --git a/redis/__init__.py b/redis/__init__.py index fd90163c30..89dee024d2 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 60799f2fe8..ecba5c47ea 100644 --- a/redis/asyncio/client.py +++ b/redis/asyncio/client.py @@ -53,6 +53,7 @@ list_or_args, ) from redis.credentials import CredentialProvider +from redis.driver_info import DriverInfo from redis.event import ( AfterPooledConnectionsInstantiationEvent, AfterPubSubConnectionInstantiationEvent, @@ -214,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, *, @@ -252,8 +258,9 @@ 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, redis_connect_func=None, @@ -306,6 +313,17 @@ 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_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() + computed_driver_info = DriverInfo(name=name, lib_version=version) + kwargs = { "db": db, "username": username, @@ -320,8 +338,7 @@ def __init__( "max_connections": max_connections, "health_check_interval": health_check_interval, "client_name": client_name, - "lib_name": 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 e3008d91d2..6576392c13 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: @@ -1190,6 +1227,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 e0d62e5216..abbb06b88a 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, @@ -199,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", @@ -240,8 +246,9 @@ 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, credential_provider: Optional[CredentialProvider] = None, @@ -280,6 +287,15 @@ 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. + 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. @@ -296,6 +312,17 @@ 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_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() + computed_driver_info = DriverInfo(name=name, lib_version=version) + kwargs = { "db": db, "username": username, @@ -309,8 +336,7 @@ def __init__( "max_connections": max_connections, "health_check_interval": health_check_interval, "client_name": client_name, - "lib_name": 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 new file mode 100644 index 0000000000..892d851313 --- /dev/null +++ b/redis/driver_info.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import List, Optional + +_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 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() + >>> 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)' + + >>> 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. + + 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..34a6017d22 100644 --- a/tests/test_asyncio/test_commands.py +++ b/tests/test_asyncio/test_commands.py @@ -528,25 +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() - 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("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"] == get_lib_version() + await r2.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 d7b56ca32f..4efc26f5c9 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -731,23 +731,36 @@ 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" - r3 = redis.Redis(lib_name=None, lib_version=None) - info = r3.client_info() - assert info["lib-name"] == "" - assert info["lib-ver"] == "" + + @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"] == 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 new file mode 100644 index 0000000000..eab2888360 --- /dev/null +++ b/tests/test_driver_info.py @@ -0,0 +1,58 @@ +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(): + 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)