pythonboot-redis 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pythonboot_redis-0.1.0/.gitignore +47 -0
- pythonboot_redis-0.1.0/PKG-INFO +75 -0
- pythonboot_redis-0.1.0/README.md +64 -0
- pythonboot_redis-0.1.0/pyproject.toml +22 -0
- pythonboot_redis-0.1.0/src/pythonboot_redis/__init__.py +8 -0
- pythonboot_redis-0.1.0/src/pythonboot_redis/async_util.py +151 -0
- pythonboot_redis-0.1.0/src/pythonboot_redis/client.py +11 -0
- pythonboot_redis-0.1.0/src/pythonboot_redis/configuration.py +72 -0
- pythonboot_redis-0.1.0/src/pythonboot_redis/properties.py +52 -0
- pythonboot_redis-0.1.0/src/pythonboot_redis/util.py +158 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
.Python
|
|
7
|
+
build/
|
|
8
|
+
develop-eggs/
|
|
9
|
+
dist/
|
|
10
|
+
downloads/
|
|
11
|
+
eggs/
|
|
12
|
+
.eggs/
|
|
13
|
+
lib/
|
|
14
|
+
lib64/
|
|
15
|
+
parts/
|
|
16
|
+
sdist/
|
|
17
|
+
var/
|
|
18
|
+
wheels/
|
|
19
|
+
share/python-wheels/
|
|
20
|
+
*.egg-info/
|
|
21
|
+
.installed.cfg
|
|
22
|
+
*.egg
|
|
23
|
+
MANIFEST
|
|
24
|
+
|
|
25
|
+
# Virtual environments
|
|
26
|
+
.venv/
|
|
27
|
+
venv/
|
|
28
|
+
ENV/
|
|
29
|
+
env/
|
|
30
|
+
|
|
31
|
+
# Tools
|
|
32
|
+
.pytest_cache/
|
|
33
|
+
.mypy_cache/
|
|
34
|
+
.ruff_cache/
|
|
35
|
+
.coverage
|
|
36
|
+
coverage.xml
|
|
37
|
+
htmlcov/
|
|
38
|
+
|
|
39
|
+
# Node.js and VitePress generated files
|
|
40
|
+
node_modules/
|
|
41
|
+
viteDocs/docs/.vitepress/cache/
|
|
42
|
+
viteDocs/docs/.vitepress/dist/
|
|
43
|
+
|
|
44
|
+
# IDE and OS
|
|
45
|
+
.idea/
|
|
46
|
+
.vscode/
|
|
47
|
+
.DS_Store
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pythonboot-redis
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Native redis-py clients, utilities and distributed locks for PythonBoot
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: pydantic-settings<3,>=2.7
|
|
7
|
+
Requires-Dist: pydantic<3,>=2.10
|
|
8
|
+
Requires-Dist: pythonboot<0.2,>=0.1.0
|
|
9
|
+
Requires-Dist: redis<8,>=5.0
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# pythonboot-redis
|
|
13
|
+
|
|
14
|
+
PythonBoot 的 redis-py 集成实现包。分发名为 `pythonboot-redis`,导入名为
|
|
15
|
+
`pythonboot_redis`;应用推荐安装依赖聚合包 `pythonboot-starter-redis`。
|
|
16
|
+
|
|
17
|
+
- Configuration Properties 绑定 `pythonboot.redis` 并注册 `RedisProperties` Bean。
|
|
18
|
+
- 默认同步模式,可选择异步;自动注入原生 `RedisClient` / `AsyncRedisClient`。
|
|
19
|
+
- 客户端类别名直接指向 redis-py 的 Redis 类,没有模板或代理包装。
|
|
20
|
+
- `RedisUtil` / `AsyncRedisUtil` 提供字符串、TTL、计数器、Hash、List、Set、有序集合操作。
|
|
21
|
+
- 原生分布式锁支持获取、释放、续期及归属判断;不通过直接删除键解锁。
|
|
22
|
+
- 工具类从当前 ContextVar 绑定的容器解析客户端,不持有全局客户端;支持命名 Bean。
|
|
23
|
+
- 默认池和客户端接入容器生命周期,异步关闭由容器等待。应用 Bean 优先。
|
|
24
|
+
- 创建对象不连接 Redis;首次命令才连接。异常按原生语义抛出,不静默返回默认值。
|
|
25
|
+
|
|
26
|
+
```yaml
|
|
27
|
+
pythonboot:
|
|
28
|
+
redis:
|
|
29
|
+
url: redis://localhost:6379/0
|
|
30
|
+
mode: sync
|
|
31
|
+
decode_responses: true
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from pythonboot import autowired, service
|
|
36
|
+
from pythonboot_redis import RedisClient, RedisUtil
|
|
37
|
+
|
|
38
|
+
@service
|
|
39
|
+
class CacheService:
|
|
40
|
+
redis_client: RedisClient = autowired()
|
|
41
|
+
|
|
42
|
+
def save(self, value: str) -> None:
|
|
43
|
+
self.redis_client.set("example:value", value, ex=60)
|
|
44
|
+
|
|
45
|
+
RedisUtil.set("example:value", "hello", ex=60)
|
|
46
|
+
value = RedisUtil.get("example:value")
|
|
47
|
+
RedisUtil.rpush("example:list", "a", "b")
|
|
48
|
+
values = RedisUtil.lrange("example:list")
|
|
49
|
+
client = RedisUtil.get_client()
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
静态调用需要在已绑定容器的请求/生命周期中,独立任务使用 `ContextUtil.use(context)`。
|
|
53
|
+
异步模式注入 `AsyncRedisClient`,使用 `await AsyncRedisUtil.get(...)`;
|
|
54
|
+
不要在异步方法中直接执行同步客户端命令。
|
|
55
|
+
工具锁默认租期 30 秒、获取等待 5 秒;每次操作创建独立锁对象,成功获取后在 finally 中释放。
|
|
56
|
+
没有自动续期,超过租期需要业务显式续期;释放过期或非本方持有的锁会抛出原生异常。
|
|
57
|
+
|
|
58
|
+
完整配置、公共 API 和锁示例见 [中文文档](../../viteDocs/docs/zh/redis.md)、
|
|
59
|
+
[English documentation](../../viteDocs/docs/en/redis.md) 和 [示例](../../examples/redis/README.md)。
|
|
60
|
+
|
|
61
|
+
## English
|
|
62
|
+
|
|
63
|
+
The implementation distribution is `pythonboot-redis`; import `pythonboot_redis`.
|
|
64
|
+
Install `pythonboot-starter-redis` in applications. Configuration Properties bind `pythonboot.redis`.
|
|
65
|
+
Native client aliases support concrete-class Bean injection; sync is the default, async is optional.
|
|
66
|
+
Static utilities resolve the current application context and provide common commands and native locks.
|
|
67
|
+
Default clients and pools are lifecycle-managed without connecting during import or construction.
|
|
68
|
+
Application Beans override defaults; providers declare cleanup for custom resources.
|
|
69
|
+
Locks use native ownership tokens, bounded acquisition, explicit renewal, and atomic release.
|
|
70
|
+
There is no automatic renewal. Use a distinct lock object per task or thread and release in finally.
|
|
71
|
+
Standalone tasks bind their container through `ContextUtil.use(context)`.
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
完整分层应用见 [全量 Starter 示例](../../example/README.md),包含全部 Starter、PostgreSQL/Redis 和同步/异步 HTTP 用法。
|
|
75
|
+
See the [complete Starter application](../../example/README.md) for all Starters and sync/async HTTP examples.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# pythonboot-redis
|
|
2
|
+
|
|
3
|
+
PythonBoot 的 redis-py 集成实现包。分发名为 `pythonboot-redis`,导入名为
|
|
4
|
+
`pythonboot_redis`;应用推荐安装依赖聚合包 `pythonboot-starter-redis`。
|
|
5
|
+
|
|
6
|
+
- Configuration Properties 绑定 `pythonboot.redis` 并注册 `RedisProperties` Bean。
|
|
7
|
+
- 默认同步模式,可选择异步;自动注入原生 `RedisClient` / `AsyncRedisClient`。
|
|
8
|
+
- 客户端类别名直接指向 redis-py 的 Redis 类,没有模板或代理包装。
|
|
9
|
+
- `RedisUtil` / `AsyncRedisUtil` 提供字符串、TTL、计数器、Hash、List、Set、有序集合操作。
|
|
10
|
+
- 原生分布式锁支持获取、释放、续期及归属判断;不通过直接删除键解锁。
|
|
11
|
+
- 工具类从当前 ContextVar 绑定的容器解析客户端,不持有全局客户端;支持命名 Bean。
|
|
12
|
+
- 默认池和客户端接入容器生命周期,异步关闭由容器等待。应用 Bean 优先。
|
|
13
|
+
- 创建对象不连接 Redis;首次命令才连接。异常按原生语义抛出,不静默返回默认值。
|
|
14
|
+
|
|
15
|
+
```yaml
|
|
16
|
+
pythonboot:
|
|
17
|
+
redis:
|
|
18
|
+
url: redis://localhost:6379/0
|
|
19
|
+
mode: sync
|
|
20
|
+
decode_responses: true
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from pythonboot import autowired, service
|
|
25
|
+
from pythonboot_redis import RedisClient, RedisUtil
|
|
26
|
+
|
|
27
|
+
@service
|
|
28
|
+
class CacheService:
|
|
29
|
+
redis_client: RedisClient = autowired()
|
|
30
|
+
|
|
31
|
+
def save(self, value: str) -> None:
|
|
32
|
+
self.redis_client.set("example:value", value, ex=60)
|
|
33
|
+
|
|
34
|
+
RedisUtil.set("example:value", "hello", ex=60)
|
|
35
|
+
value = RedisUtil.get("example:value")
|
|
36
|
+
RedisUtil.rpush("example:list", "a", "b")
|
|
37
|
+
values = RedisUtil.lrange("example:list")
|
|
38
|
+
client = RedisUtil.get_client()
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
静态调用需要在已绑定容器的请求/生命周期中,独立任务使用 `ContextUtil.use(context)`。
|
|
42
|
+
异步模式注入 `AsyncRedisClient`,使用 `await AsyncRedisUtil.get(...)`;
|
|
43
|
+
不要在异步方法中直接执行同步客户端命令。
|
|
44
|
+
工具锁默认租期 30 秒、获取等待 5 秒;每次操作创建独立锁对象,成功获取后在 finally 中释放。
|
|
45
|
+
没有自动续期,超过租期需要业务显式续期;释放过期或非本方持有的锁会抛出原生异常。
|
|
46
|
+
|
|
47
|
+
完整配置、公共 API 和锁示例见 [中文文档](../../viteDocs/docs/zh/redis.md)、
|
|
48
|
+
[English documentation](../../viteDocs/docs/en/redis.md) 和 [示例](../../examples/redis/README.md)。
|
|
49
|
+
|
|
50
|
+
## English
|
|
51
|
+
|
|
52
|
+
The implementation distribution is `pythonboot-redis`; import `pythonboot_redis`.
|
|
53
|
+
Install `pythonboot-starter-redis` in applications. Configuration Properties bind `pythonboot.redis`.
|
|
54
|
+
Native client aliases support concrete-class Bean injection; sync is the default, async is optional.
|
|
55
|
+
Static utilities resolve the current application context and provide common commands and native locks.
|
|
56
|
+
Default clients and pools are lifecycle-managed without connecting during import or construction.
|
|
57
|
+
Application Beans override defaults; providers declare cleanup for custom resources.
|
|
58
|
+
Locks use native ownership tokens, bounded acquisition, explicit renewal, and atomic release.
|
|
59
|
+
There is no automatic renewal. Use a distinct lock object per task or thread and release in finally.
|
|
60
|
+
Standalone tasks bind their container through `ContextUtil.use(context)`.
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
完整分层应用见 [全量 Starter 示例](../../example/README.md),包含全部 Starter、PostgreSQL/Redis 和同步/异步 HTTP 用法。
|
|
64
|
+
See the [complete Starter application](../../example/README.md) for all Starters and sync/async HTTP examples.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27,<2"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pythonboot-redis"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Native redis-py clients, utilities and distributed locks for PythonBoot"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"pythonboot>=0.1.0,<0.2",
|
|
13
|
+
"redis>=5.0,<8",
|
|
14
|
+
"pydantic>=2.10,<3",
|
|
15
|
+
"pydantic-settings>=2.7,<3",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.entry-points."pythonboot.autoconfigure"]
|
|
19
|
+
redis = "pythonboot_redis.configuration"
|
|
20
|
+
|
|
21
|
+
[tool.hatch.build.targets.wheel]
|
|
22
|
+
packages = ["src/pythonboot_redis"]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Public native Redis clients, configuration properties, and static utilities."""
|
|
2
|
+
|
|
3
|
+
from pythonboot_redis.async_util import AsyncRedisUtil
|
|
4
|
+
from pythonboot_redis.client import AsyncRedisClient, RedisClient
|
|
5
|
+
from pythonboot_redis.properties import RedisProperties
|
|
6
|
+
from pythonboot_redis.util import RedisUtil
|
|
7
|
+
|
|
8
|
+
__all__ = ["RedisClient", "AsyncRedisClient", "RedisProperties", "RedisUtil", "AsyncRedisUtil"]
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Asynchronous static Redis operations resolved against the current application context."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
from math import isfinite
|
|
8
|
+
|
|
9
|
+
from redis.asyncio.lock import Lock
|
|
10
|
+
|
|
11
|
+
from pythonboot import ContextUtil
|
|
12
|
+
from pythonboot_redis.client import AsyncRedisClient, RedisKey, RedisResult, RedisValue
|
|
13
|
+
from pythonboot_redis.util import validate_lock_options
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AsyncRedisUtil:
|
|
17
|
+
"""Awaitable Redis helpers without blocking calls or globally cached clients."""
|
|
18
|
+
|
|
19
|
+
@staticmethod
|
|
20
|
+
async def get_client(*, name: str | None = None, qualifier: str | None = None) -> AsyncRedisClient:
|
|
21
|
+
return await ContextUtil.aget_bean(AsyncRedisClient, name=name, qualifier=qualifier)
|
|
22
|
+
|
|
23
|
+
@staticmethod
|
|
24
|
+
async def get(key: RedisKey, *, name: str | None = None) -> RedisResult | None:
|
|
25
|
+
return await (await AsyncRedisUtil.get_client(name=name)).get(key)
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
async def set(
|
|
29
|
+
key: RedisKey, value: RedisValue, *, ex: int | timedelta | None = None,
|
|
30
|
+
px: int | timedelta | None = None, nx: bool = False, xx: bool = False,
|
|
31
|
+
keepttl: bool = False, name: str | None = None,
|
|
32
|
+
) -> bool | None:
|
|
33
|
+
client = await AsyncRedisUtil.get_client(name=name)
|
|
34
|
+
return await client.set(key, value, ex=ex, px=px, nx=nx, xx=xx, keepttl=keepttl)
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
async def delete(*keys: RedisKey, name: str | None = None) -> int:
|
|
38
|
+
return await (await AsyncRedisUtil.get_client(name=name)).delete(*keys)
|
|
39
|
+
|
|
40
|
+
@staticmethod
|
|
41
|
+
async def exists(*keys: RedisKey, name: str | None = None) -> int:
|
|
42
|
+
return await (await AsyncRedisUtil.get_client(name=name)).exists(*keys)
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
async def expire(key: RedisKey, seconds: int | timedelta, *, name: str | None = None) -> bool:
|
|
46
|
+
return await (await AsyncRedisUtil.get_client(name=name)).expire(key, seconds)
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
async def ttl(key: RedisKey, *, name: str | None = None) -> int:
|
|
50
|
+
return await (await AsyncRedisUtil.get_client(name=name)).ttl(key)
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
async def incr(key: RedisKey, amount: int = 1, *, name: str | None = None) -> int:
|
|
54
|
+
return await (await AsyncRedisUtil.get_client(name=name)).incr(key, amount)
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
async def decr(key: RedisKey, amount: int = 1, *, name: str | None = None) -> int:
|
|
58
|
+
return await (await AsyncRedisUtil.get_client(name=name)).decr(key, amount)
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
async def hset(key: RedisKey, mapping: Mapping[RedisKey, RedisValue], *, name: str | None = None) -> int:
|
|
62
|
+
return await (await AsyncRedisUtil.get_client(name=name)).hset(key, mapping=mapping)
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
async def hget(key: RedisKey, field: RedisKey, *, name: str | None = None) -> RedisResult | None:
|
|
66
|
+
return await (await AsyncRedisUtil.get_client(name=name)).hget(key, field)
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
async def hgetall(key: RedisKey, *, name: str | None = None) -> dict[RedisResult, RedisResult]:
|
|
70
|
+
return await (await AsyncRedisUtil.get_client(name=name)).hgetall(key)
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
async def hdel(key: RedisKey, *fields: RedisKey, name: str | None = None) -> int:
|
|
74
|
+
return await (await AsyncRedisUtil.get_client(name=name)).hdel(key, *fields)
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
async def lpush(key: RedisKey, *values: RedisValue, name: str | None = None) -> int:
|
|
78
|
+
return await (await AsyncRedisUtil.get_client(name=name)).lpush(key, *values)
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
async def rpush(key: RedisKey, *values: RedisValue, name: str | None = None) -> int:
|
|
82
|
+
return await (await AsyncRedisUtil.get_client(name=name)).rpush(key, *values)
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
async def lpop(key: RedisKey, *, name: str | None = None) -> RedisResult | None:
|
|
86
|
+
return await (await AsyncRedisUtil.get_client(name=name)).lpop(key)
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
async def rpop(key: RedisKey, *, name: str | None = None) -> RedisResult | None:
|
|
90
|
+
return await (await AsyncRedisUtil.get_client(name=name)).rpop(key)
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
async def lrange(key: RedisKey, start: int = 0, end: int = -1, *, name: str | None = None) -> list[RedisResult]:
|
|
94
|
+
return await (await AsyncRedisUtil.get_client(name=name)).lrange(key, start, end)
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
async def llen(key: RedisKey, *, name: str | None = None) -> int:
|
|
98
|
+
return await (await AsyncRedisUtil.get_client(name=name)).llen(key)
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
async def sadd(key: RedisKey, *values: RedisValue, name: str | None = None) -> int:
|
|
102
|
+
return await (await AsyncRedisUtil.get_client(name=name)).sadd(key, *values)
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
async def smembers(key: RedisKey, *, name: str | None = None) -> set[RedisResult]:
|
|
106
|
+
return await (await AsyncRedisUtil.get_client(name=name)).smembers(key)
|
|
107
|
+
|
|
108
|
+
@staticmethod
|
|
109
|
+
async def srem(key: RedisKey, *values: RedisValue, name: str | None = None) -> int:
|
|
110
|
+
return await (await AsyncRedisUtil.get_client(name=name)).srem(key, *values)
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
async def zadd(key: RedisKey, mapping: Mapping[RedisKey, float], *, name: str | None = None) -> int:
|
|
114
|
+
return await (await AsyncRedisUtil.get_client(name=name)).zadd(key, mapping)
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
async def zrange(key: RedisKey, start: int = 0, end: int = -1, *, name: str | None = None) -> list[RedisResult]:
|
|
118
|
+
return await (await AsyncRedisUtil.get_client(name=name)).zrange(key, start, end)
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
async def zrem(key: RedisKey, *values: RedisValue, name: str | None = None) -> int:
|
|
122
|
+
return await (await AsyncRedisUtil.get_client(name=name)).zrem(key, *values)
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
async def get_lock(
|
|
126
|
+
key: RedisKey, *, timeout: float = 30, blocking_timeout: float = 5,
|
|
127
|
+
sleep: float = 0.1, thread_local: bool = True, name: str | None = None,
|
|
128
|
+
) -> Lock:
|
|
129
|
+
validate_lock_options(timeout, blocking_timeout, sleep)
|
|
130
|
+
client = await AsyncRedisUtil.get_client(name=name)
|
|
131
|
+
return client.lock(
|
|
132
|
+
key, timeout=timeout, blocking_timeout=blocking_timeout, sleep=sleep, thread_local=thread_local,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
async def acquire_lock(lock: Lock, *, blocking: bool = True) -> bool:
|
|
137
|
+
return await lock.acquire(blocking=blocking)
|
|
138
|
+
|
|
139
|
+
@staticmethod
|
|
140
|
+
async def release_lock(lock: Lock) -> None:
|
|
141
|
+
await lock.release()
|
|
142
|
+
|
|
143
|
+
@staticmethod
|
|
144
|
+
async def extend_lock(lock: Lock, seconds: float, *, replace_ttl: bool = False) -> bool:
|
|
145
|
+
if not isfinite(seconds) or seconds <= 0:
|
|
146
|
+
raise ValueError("Lock extension must be positive")
|
|
147
|
+
return await lock.extend(seconds, replace_ttl=replace_ttl)
|
|
148
|
+
|
|
149
|
+
@staticmethod
|
|
150
|
+
async def lock_owned(lock: Lock) -> bool:
|
|
151
|
+
return await lock.owned()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Concrete aliases of native redis-py client classes for Bean injection."""
|
|
2
|
+
|
|
3
|
+
from redis import Redis
|
|
4
|
+
from redis.asyncio import Redis as AsyncRedis
|
|
5
|
+
|
|
6
|
+
RedisClient = Redis
|
|
7
|
+
AsyncRedisClient = AsyncRedis
|
|
8
|
+
|
|
9
|
+
RedisKey = str | bytes
|
|
10
|
+
RedisValue = str | bytes | int | float
|
|
11
|
+
RedisResult = str | bytes
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Conditional native Redis clients and application-owned pool lifecycle."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from redis import ConnectionPool
|
|
6
|
+
from redis.asyncio import ConnectionPool as AsyncConnectionPool
|
|
7
|
+
|
|
8
|
+
from pythonboot import auto_configuration, bean, conditional_on_missing_bean, conditional_on_property
|
|
9
|
+
from pythonboot_redis.client import AsyncRedisClient, RedisClient
|
|
10
|
+
from pythonboot_redis.properties import RedisProperties
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@auto_configuration(order=2900, imports=(RedisProperties,))
|
|
16
|
+
@conditional_on_property("pythonboot.redis.enabled", having_value=True, match_if_missing=True)
|
|
17
|
+
class RedisPropertiesAutoConfiguration:
|
|
18
|
+
"""Register typed Redis connection configuration before resource factories."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@auto_configuration(order=3000)
|
|
22
|
+
@conditional_on_property("pythonboot.redis.enabled", having_value=True, match_if_missing=True)
|
|
23
|
+
@conditional_on_property("pythonboot.redis.mode", having_value="sync", match_if_missing=True)
|
|
24
|
+
class RedisSyncPoolAutoConfiguration:
|
|
25
|
+
"""Create a pool only when neither an application client nor a pool is available."""
|
|
26
|
+
|
|
27
|
+
@bean(name="redisConnectionPool", destroy_method="disconnect")
|
|
28
|
+
@conditional_on_missing_bean(ConnectionPool, RedisClient, "redisConnectionPool", "redisClient")
|
|
29
|
+
def connection_pool(self, properties: RedisProperties) -> ConnectionPool:
|
|
30
|
+
logger.info("Creating synchronous Redis connection pool with max_connections=%d.", properties.max_connections)
|
|
31
|
+
return ConnectionPool.from_url(properties.connection_url("sync"), **properties.connection_options())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@auto_configuration(order=3000)
|
|
35
|
+
@conditional_on_property("pythonboot.redis.enabled", having_value=True, match_if_missing=True)
|
|
36
|
+
@conditional_on_property("pythonboot.redis.mode", having_value="async")
|
|
37
|
+
class RedisAsyncPoolAutoConfiguration:
|
|
38
|
+
"""Create an asynchronous pool with awaited shutdown."""
|
|
39
|
+
|
|
40
|
+
@bean(name="redisAsyncConnectionPool", destroy_method="disconnect")
|
|
41
|
+
@conditional_on_missing_bean(
|
|
42
|
+
AsyncConnectionPool, AsyncRedisClient, "redisAsyncConnectionPool", "redisAsyncClient",
|
|
43
|
+
)
|
|
44
|
+
def connection_pool(self, properties: RedisProperties) -> AsyncConnectionPool:
|
|
45
|
+
logger.info("Creating asynchronous Redis connection pool with max_connections=%d.", properties.max_connections)
|
|
46
|
+
return AsyncConnectionPool.from_url(properties.connection_url("async"), **properties.connection_options())
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@auto_configuration(order=3100)
|
|
50
|
+
@conditional_on_property("pythonboot.redis.enabled", having_value=True, match_if_missing=True)
|
|
51
|
+
@conditional_on_property("pythonboot.redis.mode", having_value="sync", match_if_missing=True)
|
|
52
|
+
class RedisSyncClientAutoConfiguration:
|
|
53
|
+
"""Register a native synchronous Redis client using the selected pool."""
|
|
54
|
+
|
|
55
|
+
@bean(name="redisClient", destroy_method="close")
|
|
56
|
+
@conditional_on_missing_bean(RedisClient, "redisClient")
|
|
57
|
+
def client(self, pool: ConnectionPool) -> RedisClient:
|
|
58
|
+
logger.info("Creating synchronous Redis client from the managed connection pool.")
|
|
59
|
+
return RedisClient(connection_pool=pool)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@auto_configuration(order=3100)
|
|
63
|
+
@conditional_on_property("pythonboot.redis.enabled", having_value=True, match_if_missing=True)
|
|
64
|
+
@conditional_on_property("pythonboot.redis.mode", having_value="async")
|
|
65
|
+
class RedisAsyncClientAutoConfiguration:
|
|
66
|
+
"""Register a native asynchronous Redis client without opening connections during import."""
|
|
67
|
+
|
|
68
|
+
@bean(name="redisAsyncClient", destroy_method="aclose")
|
|
69
|
+
@conditional_on_missing_bean(AsyncRedisClient, "redisAsyncClient")
|
|
70
|
+
def client(self, pool: AsyncConnectionPool) -> AsyncRedisClient:
|
|
71
|
+
logger.info("Creating asynchronous Redis client from the managed connection pool.")
|
|
72
|
+
return AsyncRedisClient(connection_pool=pool)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Connection settings bound through PythonBoot Configuration Properties."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import Field, SecretStr
|
|
6
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
7
|
+
|
|
8
|
+
from pythonboot import configuration_properties
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@configuration_properties("pythonboot.redis")
|
|
12
|
+
class RedisProperties(BaseSettings):
|
|
13
|
+
"""Native standalone Redis connection options; connections open on first command."""
|
|
14
|
+
|
|
15
|
+
model_config = SettingsConfigDict(env_prefix="PYTHONBOOT_REDIS_", extra="forbid")
|
|
16
|
+
|
|
17
|
+
enabled: bool = True
|
|
18
|
+
mode: Literal["sync", "async"] = "sync"
|
|
19
|
+
url: SecretStr = SecretStr("redis://localhost:6379/0")
|
|
20
|
+
username: str | None = None
|
|
21
|
+
password: SecretStr | None = None
|
|
22
|
+
decode_responses: bool = True
|
|
23
|
+
encoding: str = Field(default="utf-8", min_length=1)
|
|
24
|
+
max_connections: int = Field(default=50, ge=1)
|
|
25
|
+
socket_timeout: float = Field(default=5, gt=0, allow_inf_nan=False)
|
|
26
|
+
socket_connect_timeout: float = Field(default=5, gt=0, allow_inf_nan=False)
|
|
27
|
+
health_check_interval: int = Field(default=30, ge=0)
|
|
28
|
+
|
|
29
|
+
def connection_options(self) -> dict[str, Any]:
|
|
30
|
+
"""Return keyword options without exposing secret representations."""
|
|
31
|
+
options: dict[str, Any] = {
|
|
32
|
+
"decode_responses": self.decode_responses,
|
|
33
|
+
"encoding": self.encoding,
|
|
34
|
+
"max_connections": self.max_connections,
|
|
35
|
+
"socket_timeout": self.socket_timeout,
|
|
36
|
+
"socket_connect_timeout": self.socket_connect_timeout,
|
|
37
|
+
"health_check_interval": self.health_check_interval,
|
|
38
|
+
}
|
|
39
|
+
if self.username is not None:
|
|
40
|
+
options["username"] = self.username
|
|
41
|
+
if self.password is not None:
|
|
42
|
+
options["password"] = self.password.get_secret_value()
|
|
43
|
+
return options
|
|
44
|
+
|
|
45
|
+
def connection_url(self, mode: Literal["sync", "async"]) -> str:
|
|
46
|
+
"""Validate selection before constructing a default connection pool."""
|
|
47
|
+
if not self.enabled or self.mode != mode:
|
|
48
|
+
raise ValueError("Redis enabled and mode must be selected in application YAML")
|
|
49
|
+
url = self.url.get_secret_value()
|
|
50
|
+
if not url.strip():
|
|
51
|
+
raise ValueError("pythonboot.redis.url must not be empty")
|
|
52
|
+
return url
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Synchronous static Redis operations resolved against the current application context."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
from math import isfinite
|
|
8
|
+
|
|
9
|
+
from redis.lock import Lock
|
|
10
|
+
|
|
11
|
+
from pythonboot import ContextUtil
|
|
12
|
+
from pythonboot_redis.client import RedisClient, RedisKey, RedisResult, RedisValue
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def validate_lock_options(timeout: float, blocking_timeout: float, sleep: float) -> None:
|
|
16
|
+
"""Require bounded acquisition and a positive finite lease."""
|
|
17
|
+
if not isfinite(timeout) or timeout <= 0:
|
|
18
|
+
raise ValueError("Lock timeout must be finite and positive")
|
|
19
|
+
if not isfinite(blocking_timeout) or blocking_timeout < 0:
|
|
20
|
+
raise ValueError("Lock blocking_timeout must be finite and non-negative")
|
|
21
|
+
if not isfinite(sleep) or sleep <= 0:
|
|
22
|
+
raise ValueError("Lock sleep must be finite and positive")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RedisUtil:
|
|
26
|
+
"""Convenience methods backed by native RedisClient Beans, never a global client."""
|
|
27
|
+
|
|
28
|
+
@staticmethod
|
|
29
|
+
def get_client(*, name: str | None = None, qualifier: str | None = None) -> RedisClient:
|
|
30
|
+
return ContextUtil.get_bean(RedisClient, name=name, qualifier=qualifier)
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def get(key: RedisKey, *, name: str | None = None) -> RedisResult | None:
|
|
34
|
+
return RedisUtil.get_client(name=name).get(key)
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
def set(
|
|
38
|
+
key: RedisKey, value: RedisValue, *, ex: int | timedelta | None = None,
|
|
39
|
+
px: int | timedelta | None = None, nx: bool = False, xx: bool = False,
|
|
40
|
+
keepttl: bool = False, name: str | None = None,
|
|
41
|
+
) -> bool | None:
|
|
42
|
+
return RedisUtil.get_client(name=name).set(key, value, ex=ex, px=px, nx=nx, xx=xx, keepttl=keepttl)
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
def delete(*keys: RedisKey, name: str | None = None) -> int:
|
|
46
|
+
return RedisUtil.get_client(name=name).delete(*keys)
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def exists(*keys: RedisKey, name: str | None = None) -> int:
|
|
50
|
+
return RedisUtil.get_client(name=name).exists(*keys)
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def expire(key: RedisKey, seconds: int | timedelta, *, name: str | None = None) -> bool:
|
|
54
|
+
return RedisUtil.get_client(name=name).expire(key, seconds)
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
def ttl(key: RedisKey, *, name: str | None = None) -> int:
|
|
58
|
+
return RedisUtil.get_client(name=name).ttl(key)
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def incr(key: RedisKey, amount: int = 1, *, name: str | None = None) -> int:
|
|
62
|
+
return RedisUtil.get_client(name=name).incr(key, amount)
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def decr(key: RedisKey, amount: int = 1, *, name: str | None = None) -> int:
|
|
66
|
+
return RedisUtil.get_client(name=name).decr(key, amount)
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def hset(key: RedisKey, mapping: Mapping[RedisKey, RedisValue], *, name: str | None = None) -> int:
|
|
70
|
+
return RedisUtil.get_client(name=name).hset(key, mapping=mapping)
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
def hget(key: RedisKey, field: RedisKey, *, name: str | None = None) -> RedisResult | None:
|
|
74
|
+
return RedisUtil.get_client(name=name).hget(key, field)
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def hgetall(key: RedisKey, *, name: str | None = None) -> dict[RedisResult, RedisResult]:
|
|
78
|
+
return RedisUtil.get_client(name=name).hgetall(key)
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
def hdel(key: RedisKey, *fields: RedisKey, name: str | None = None) -> int:
|
|
82
|
+
return RedisUtil.get_client(name=name).hdel(key, *fields)
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def lpush(key: RedisKey, *values: RedisValue, name: str | None = None) -> int:
|
|
86
|
+
return RedisUtil.get_client(name=name).lpush(key, *values)
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def rpush(key: RedisKey, *values: RedisValue, name: str | None = None) -> int:
|
|
90
|
+
return RedisUtil.get_client(name=name).rpush(key, *values)
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def lpop(key: RedisKey, *, name: str | None = None) -> RedisResult | None:
|
|
94
|
+
return RedisUtil.get_client(name=name).lpop(key)
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def rpop(key: RedisKey, *, name: str | None = None) -> RedisResult | None:
|
|
98
|
+
return RedisUtil.get_client(name=name).rpop(key)
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def lrange(key: RedisKey, start: int = 0, end: int = -1, *, name: str | None = None) -> list[RedisResult]:
|
|
102
|
+
return RedisUtil.get_client(name=name).lrange(key, start, end)
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def llen(key: RedisKey, *, name: str | None = None) -> int:
|
|
106
|
+
return RedisUtil.get_client(name=name).llen(key)
|
|
107
|
+
|
|
108
|
+
@staticmethod
|
|
109
|
+
def sadd(key: RedisKey, *values: RedisValue, name: str | None = None) -> int:
|
|
110
|
+
return RedisUtil.get_client(name=name).sadd(key, *values)
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def smembers(key: RedisKey, *, name: str | None = None) -> set[RedisResult]:
|
|
114
|
+
return RedisUtil.get_client(name=name).smembers(key)
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
def srem(key: RedisKey, *values: RedisValue, name: str | None = None) -> int:
|
|
118
|
+
return RedisUtil.get_client(name=name).srem(key, *values)
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def zadd(key: RedisKey, mapping: Mapping[RedisKey, float], *, name: str | None = None) -> int:
|
|
122
|
+
return RedisUtil.get_client(name=name).zadd(key, mapping)
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def zrange(key: RedisKey, start: int = 0, end: int = -1, *, name: str | None = None) -> list[RedisResult]:
|
|
126
|
+
return RedisUtil.get_client(name=name).zrange(key, start, end)
|
|
127
|
+
|
|
128
|
+
@staticmethod
|
|
129
|
+
def zrem(key: RedisKey, *values: RedisValue, name: str | None = None) -> int:
|
|
130
|
+
return RedisUtil.get_client(name=name).zrem(key, *values)
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def get_lock(
|
|
134
|
+
key: RedisKey, *, timeout: float = 30, blocking_timeout: float = 5,
|
|
135
|
+
sleep: float = 0.1, thread_local: bool = True, name: str | None = None,
|
|
136
|
+
) -> Lock:
|
|
137
|
+
validate_lock_options(timeout, blocking_timeout, sleep)
|
|
138
|
+
return RedisUtil.get_client(name=name).lock(
|
|
139
|
+
key, timeout=timeout, blocking_timeout=blocking_timeout, sleep=sleep, thread_local=thread_local,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def acquire_lock(lock: Lock, *, blocking: bool = True) -> bool:
|
|
144
|
+
return lock.acquire(blocking=blocking)
|
|
145
|
+
|
|
146
|
+
@staticmethod
|
|
147
|
+
def release_lock(lock: Lock) -> None:
|
|
148
|
+
lock.release()
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def extend_lock(lock: Lock, seconds: float, *, replace_ttl: bool = False) -> bool:
|
|
152
|
+
if not isfinite(seconds) or seconds <= 0:
|
|
153
|
+
raise ValueError("Lock extension must be positive")
|
|
154
|
+
return lock.extend(seconds, replace_ttl=replace_ttl)
|
|
155
|
+
|
|
156
|
+
@staticmethod
|
|
157
|
+
def lock_owned(lock: Lock) -> bool:
|
|
158
|
+
return lock.owned()
|