Skip to content

Commit a2c049f

Browse files
committed
Add DriverInfo class for upstream driver tracking
1 parent 793af91 commit a2c049f

File tree

7 files changed

+214
-2
lines changed

7 files changed

+214
-2
lines changed

redis/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from redis import asyncio # noqa
22
from redis.backoff import default_backoff
33
from redis.client import Redis, StrictRedis
4+
from redis.driver_info import DriverInfo
45
from redis.cluster import RedisCluster
56
from redis.connection import (
67
BlockingConnectionPool,
@@ -63,6 +64,7 @@ def int_or_str(value):
6364
"CredentialProvider",
6465
"CrossSlotTransactionError",
6566
"DataError",
67+
"DriverInfo",
6668
"from_url",
6769
"default_backoff",
6870
"InvalidPipelineStack",

redis/asyncio/client.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
)
4040
from redis.asyncio.lock import Lock
4141
from redis.asyncio.retry import Retry
42+
from redis.driver_info import DriverInfo
4243
from redis.backoff import ExponentialWithJitterBackoff
4344
from redis.client import (
4445
EMPTY_RESPONSE,
@@ -252,6 +253,7 @@ def __init__(
252253
client_name: Optional[str] = None,
253254
lib_name: Optional[str] = "redis-py",
254255
lib_version: Optional[str] = get_lib_version(),
256+
driver_info: Optional["DriverInfo"] = None,
255257
username: Optional[str] = None,
256258
auto_close_connection_pool: Optional[bool] = None,
257259
redis_connect_func=None,
@@ -304,6 +306,11 @@ def __init__(
304306
# Create internal connection pool, expected to be closed by Redis instance
305307
if not retry_on_error:
306308
retry_on_error = []
309+
if driver_info is not None:
310+
computed_lib_name = driver_info.formatted_name
311+
else:
312+
computed_lib_name = lib_name
313+
307314
kwargs = {
308315
"db": db,
309316
"username": username,
@@ -318,7 +325,7 @@ def __init__(
318325
"max_connections": max_connections,
319326
"health_check_interval": health_check_interval,
320327
"client_name": client_name,
321-
"lib_name": lib_name,
328+
"lib_name": computed_lib_name,
322329
"lib_version": lib_version,
323330
"redis_connect_func": redis_connect_func,
324331
"protocol": protocol,

redis/client.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
MaintNotificationsConfig,
6161
)
6262
from redis.retry import Retry
63+
from redis.driver_info import DriverInfo
6364
from redis.utils import (
6465
_set_info_logger,
6566
deprecated_args,
@@ -242,6 +243,7 @@ def __init__(
242243
client_name: Optional[str] = None,
243244
lib_name: Optional[str] = "redis-py",
244245
lib_version: Optional[str] = get_lib_version(),
246+
driver_info: Optional["DriverInfo"] = None,
245247
username: Optional[str] = None,
246248
redis_connect_func: Optional[Callable[[], None]] = None,
247249
credential_provider: Optional[CredentialProvider] = None,
@@ -280,6 +282,9 @@ def __init__(
280282
decode_responses:
281283
if `True`, the response will be decoded to utf-8.
282284
Argument is ignored when connection_pool is provided.
285+
driver_info:
286+
Optional DriverInfo object to identify upstream libraries.
287+
Argument is ignored when connection_pool is provided.
283288
maint_notifications_config:
284289
configuration the pool to support maintenance notifications - see
285290
`redis.maint_notifications.MaintNotificationsConfig` for details.
@@ -296,6 +301,11 @@ def __init__(
296301
if not connection_pool:
297302
if not retry_on_error:
298303
retry_on_error = []
304+
if driver_info is not None:
305+
computed_lib_name = driver_info.formatted_name
306+
else:
307+
computed_lib_name = lib_name
308+
299309
kwargs = {
300310
"db": db,
301311
"username": username,
@@ -309,7 +319,7 @@ def __init__(
309319
"max_connections": max_connections,
310320
"health_check_interval": health_check_interval,
311321
"client_name": client_name,
312-
"lib_name": lib_name,
322+
"lib_name": computed_lib_name,
313323
"lib_version": lib_version,
314324
"redis_connect_func": redis_connect_func,
315325
"credential_provider": credential_provider,

redis/driver_info.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass, field
4+
from typing import List
5+
6+
_BRACES = {"(", ")", "[", "]", "{", "}"}
7+
8+
9+
def _validate_no_invalid_chars(value: str, field_name: str) -> None:
10+
"""Ensure value contains only printable ASCII without spaces or braces.
11+
12+
This mirrors the constraints enforced by other Redis clients for values that
13+
will appear in CLIENT LIST / CLIENT INFO output.
14+
"""
15+
16+
for ch in value:
17+
# printable ASCII without space: '!' (0x21) to '~' (0x7E)
18+
if ord(ch) < 0x21 or ord(ch) > 0x7E or ch in _BRACES:
19+
raise ValueError(
20+
f"{field_name} must not contain spaces, newlines, non-printable characters, or braces"
21+
)
22+
23+
24+
def _validate_driver_name(name: str) -> None:
25+
"""Validate an upstream driver name.
26+
27+
The name should look like a typical Python distribution or package name,
28+
following a simplified form of PEP 503 normalisation rules:
29+
30+
* start with a lowercase ASCII letter
31+
* contain only lowercase letters, digits, hyphens and underscores
32+
33+
Examples of valid names: ``"django-redis"``, ``"celery"``, ``"rq"``.
34+
"""
35+
36+
import re
37+
38+
_validate_no_invalid_chars(name, "Driver name")
39+
if not re.match(r"^[a-z][a-z0-9_-]*$", name):
40+
raise ValueError(
41+
"Upstream driver name must use a Python package-style name: "
42+
"start with a lowercase letter and contain only lowercase letters, "
43+
"digits, hyphens, and underscores (e.g., 'django-redis')."
44+
)
45+
46+
47+
def _validate_driver_version(version: str) -> None:
48+
_validate_no_invalid_chars(version, "Driver version")
49+
50+
51+
def _format_driver_entry(driver_name: str, driver_version: str) -> str:
52+
return f"{driver_name}_v{driver_version}"
53+
54+
55+
@dataclass
56+
class DriverInfo:
57+
"""Driver information used to build the CLIENT SETINFO LIB-NAME value.
58+
59+
The formatted name follows the pattern::
60+
61+
name(driver1_vVersion1;driver2_vVersion2)
62+
63+
Examples
64+
--------
65+
>>> info = DriverInfo()
66+
>>> info.formatted_name
67+
'redis-py'
68+
69+
>>> info = DriverInfo().add_upstream_driver("django-redis", "5.4.0")
70+
>>> info.formatted_name
71+
'redis-py(django-redis_v5.4.0)'
72+
"""
73+
74+
name: str = "redis-py"
75+
_upstream: List[str] = field(default_factory=list)
76+
77+
@property
78+
def upstream_drivers(self) -> List[str]:
79+
"""Return a copy of the upstream driver entries.
80+
81+
Each entry is in the form ``"driver-name_vversion"``.
82+
"""
83+
84+
return list(self._upstream)
85+
86+
def add_upstream_driver(
87+
self, driver_name: str, driver_version: str
88+
) -> "DriverInfo":
89+
"""Add an upstream driver to this instance and return self.
90+
91+
The most recently added driver appears first in :pyattr:`formatted_name`.
92+
"""
93+
94+
if driver_name is None:
95+
raise ValueError("Driver name must not be None")
96+
if driver_version is None:
97+
raise ValueError("Driver version must not be None")
98+
99+
_validate_driver_name(driver_name)
100+
_validate_driver_version(driver_version)
101+
102+
entry = _format_driver_entry(driver_name, driver_version)
103+
# insert at the beginning so latest is first
104+
self._upstream.insert(0, entry)
105+
return self
106+
107+
@property
108+
def formatted_name(self) -> str:
109+
"""Return the base name with upstream drivers encoded, if any.
110+
111+
With no upstream drivers, this is just :pyattr:`name`. Otherwise::
112+
113+
name(driver1_vX;driver2_vY)
114+
"""
115+
116+
if not self._upstream:
117+
return self.name
118+
return f"{self.name}({';'.join(self._upstream)})"

tests/test_asyncio/test_commands.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,20 @@ async def test_client_setinfo(self, r: redis.Redis):
541541
info = await r2.client_info()
542542
assert info["lib-name"] == "test2"
543543
assert info["lib-ver"] == "1234"
544+
545+
@skip_if_server_version_lt("7.2.0")
546+
async def test_client_setinfo_with_driver_info(self, r: redis.Redis):
547+
from redis import DriverInfo
548+
549+
info = DriverInfo().add_upstream_driver("celery", "5.4.1")
550+
r2 = redis.asyncio.Redis(driver_info=info)
551+
await r2.ping()
552+
client_info = await r2.client_info()
553+
assert (
554+
client_info["lib-name"]
555+
== "redis-py(celery_v5.4.1)"
556+
)
557+
assert client_info["lib-ver"] == redis.__version__
544558
await r2.aclose()
545559
r3 = redis.asyncio.Redis(lib_name=None, lib_version=None)
546560
info = await r3.client_info()

tests/test_commands.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -744,6 +744,17 @@ def test_client_setinfo(self, r: redis.Redis):
744744
info = r2.client_info()
745745
assert info["lib-name"] == "test2"
746746
assert info["lib-ver"] == "1234"
747+
748+
@skip_if_server_version_lt("7.2.0")
749+
def test_client_setinfo_with_driver_info(self, r: redis.Redis):
750+
from redis import DriverInfo
751+
752+
info = DriverInfo().add_upstream_driver("django-redis", "5.4.0")
753+
r2 = redis.Redis(driver_info=info)
754+
r2.ping()
755+
client_info = r2.client_info()
756+
assert client_info["lib-name"] == "redis-py(django-redis_v5.4.0)"
757+
assert client_info["lib-ver"] == redis.__version__
747758
r3 = redis.Redis(lib_name=None, lib_version=None)
748759
info = r3.client_info()
749760
assert info["lib-name"] == ""

tests/test_driver_info.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import pytest
2+
3+
from redis.driver_info import DriverInfo
4+
5+
6+
def test_driver_info_default_name_no_upstream():
7+
info = DriverInfo()
8+
assert info.formatted_name == "redis-py"
9+
assert info.upstream_drivers == []
10+
11+
12+
def test_driver_info_single_upstream():
13+
info = DriverInfo().add_upstream_driver("django-redis", "5.4.0")
14+
assert info.formatted_name == "redis-py(django-redis_v5.4.0)"
15+
16+
17+
def test_driver_info_multiple_upstreams_latest_first():
18+
info = DriverInfo()
19+
info.add_upstream_driver("django-redis", "5.4.0")
20+
info.add_upstream_driver("celery", "5.4.1")
21+
assert info.formatted_name == "redis-py(celery_v5.4.1;django-redis_v5.4.0)"
22+
23+
24+
@pytest.mark.parametrize(
25+
"name",
26+
[
27+
"DjangoRedis", # must start with lowercase
28+
"django redis", # spaces not allowed
29+
"django{redis}", # braces not allowed
30+
"django:redis", # ':' not allowed by validation regex
31+
],
32+
)
33+
def test_driver_info_invalid_name(name):
34+
info = DriverInfo()
35+
with pytest.raises(ValueError):
36+
info.add_upstream_driver(name, "3.2.0")
37+
38+
39+
@pytest.mark.parametrize(
40+
"version",
41+
[
42+
"3.2.0 beta", # space not allowed
43+
"3.2.0)", # brace not allowed
44+
"3.2.0\n", # newline not allowed
45+
],
46+
)
47+
def test_driver_info_invalid_version(version):
48+
info = DriverInfo()
49+
with pytest.raises(ValueError):
50+
info.add_upstream_driver("django-redis", version)

0 commit comments

Comments
 (0)