agstack 1.16.2__tar.gz → 1.17.1__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.
- {agstack-1.16.2 → agstack-1.17.1}/PKG-INFO +20 -5
- agstack-1.17.1/agstack/cache/__init__.py +14 -0
- agstack-1.17.1/agstack/cache/base.py +39 -0
- agstack-1.17.1/agstack/cache/memory.py +66 -0
- agstack-1.17.1/agstack/cache/redis.py +63 -0
- agstack-1.17.1/agstack/messagebus/__init__.py +14 -0
- agstack-1.17.1/agstack/messagebus/base.py +42 -0
- agstack-1.17.1/agstack/messagebus/memory.py +54 -0
- agstack-1.17.1/agstack/messagebus/redis.py +94 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack.egg-info/PKG-INFO +20 -5
- {agstack-1.16.2 → agstack-1.17.1}/agstack.egg-info/SOURCES.txt +13 -1
- {agstack-1.16.2 → agstack-1.17.1}/agstack.egg-info/requires.txt +27 -4
- {agstack-1.16.2 → agstack-1.17.1}/pyproject.toml +19 -6
- agstack-1.17.1/tests/test_cache_memory.py +145 -0
- agstack-1.17.1/tests/test_cache_redis.py +101 -0
- agstack-1.17.1/tests/test_messagebus_memory.py +71 -0
- agstack-1.17.1/tests/test_messagebus_redis.py +73 -0
- {agstack-1.16.2 → agstack-1.17.1}/LICENSE +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/README.md +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/__init__.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/config/__init__.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/config/logger.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/config/manager.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/config/types.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/contexts.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/decorators.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/events.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/exceptions.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/fastapi/__init__.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/fastapi/exception.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/fastapi/middleware.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/fastapi/offline.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/fastapi/sse.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/infra/db/__init__.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/infra/es/__init__.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/infra/kg/__init__.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/infra/mq/__init__.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/__init__.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/client.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/__init__.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/agent.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/context.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/event.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/exceptions.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/factory.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/flow.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/loader.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/nodes/__init__.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/nodes/agent_node.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/nodes/base.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/nodes/detect_node.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/nodes/echo_node.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/nodes/llm_chat_node.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/nodes/llm_embed_node.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/nodes/llm_rerank_node.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/nodes/python_node.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/nodes/subflow_node.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/nodes/switch_node.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/nodes/tool_node.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/records.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/registry.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/sandbox.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/state.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/tool.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/prompts.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/token.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/schema.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/security/__init__.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/security/casbin.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/security/crypt.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack/status.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack.egg-info/dependency_links.txt +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/agstack.egg-info/top_level.txt +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/setup.cfg +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/tests/test_flow_io.py +0 -0
- {agstack-1.16.2 → agstack-1.17.1}/tests/test_flow_switch_subflow.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agstack
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.17.1
|
|
4
4
|
Summary: Production-ready toolkit for building FastAPI and LLM applications
|
|
5
5
|
Author-email: XtraVisions <gitadmin@xtravisions.com>, Chen Hao <chenhao@xtravisions.com>
|
|
6
6
|
Maintainer-email: XtraVisions <gitadmin@xtravisions.com>, Chen Hao <chenhao@xtravisions.com>
|
|
@@ -20,13 +20,9 @@ Classifier: Typing :: Typed
|
|
|
20
20
|
Requires-Python: >=3.12
|
|
21
21
|
Description-Content-Type: text/markdown
|
|
22
22
|
License-File: LICENSE
|
|
23
|
-
Requires-Dist: aio-pika>=9.6.2
|
|
24
|
-
Requires-Dist: asyncpg>=0.30.0
|
|
25
|
-
Requires-Dist: elasticsearch[async]>=9.3.0
|
|
26
23
|
Requires-Dist: fastapi>=0.136.1
|
|
27
24
|
Requires-Dist: jwcrypto>=1.5.7
|
|
28
25
|
Requires-Dist: loguru>=0.7.3
|
|
29
|
-
Requires-Dist: nebula3-python>=3.8.3
|
|
30
26
|
Requires-Dist: openai>=2.34.0
|
|
31
27
|
Requires-Dist: bcrypt>=4.0.0
|
|
32
28
|
Requires-Dist: pycasbin>=2.8.0
|
|
@@ -37,6 +33,25 @@ Requires-Dist: RestrictedPython>=7.0
|
|
|
37
33
|
Requires-Dist: sqlobjects>=1.9.1
|
|
38
34
|
Requires-Dist: tiktoken>=0.12.0
|
|
39
35
|
Requires-Dist: uvicorn>=0.46.0
|
|
36
|
+
Provides-Extra: mq
|
|
37
|
+
Requires-Dist: aio-pika>=9.6.2; extra == "mq"
|
|
38
|
+
Provides-Extra: db
|
|
39
|
+
Requires-Dist: asyncpg>=0.30.0; extra == "db"
|
|
40
|
+
Provides-Extra: es
|
|
41
|
+
Requires-Dist: elasticsearch[async]>=9.3.0; extra == "es"
|
|
42
|
+
Provides-Extra: graph
|
|
43
|
+
Requires-Dist: nebula3-python>=3.8.3; extra == "graph"
|
|
44
|
+
Provides-Extra: redis
|
|
45
|
+
Requires-Dist: redis>=5.0; extra == "redis"
|
|
46
|
+
Provides-Extra: std
|
|
47
|
+
Requires-Dist: agstack[mq]; extra == "std"
|
|
48
|
+
Requires-Dist: agstack[db]; extra == "std"
|
|
49
|
+
Provides-Extra: pro
|
|
50
|
+
Requires-Dist: agstack[std]; extra == "pro"
|
|
51
|
+
Requires-Dist: agstack[es]; extra == "pro"
|
|
52
|
+
Provides-Extra: ent
|
|
53
|
+
Requires-Dist: agstack[pro]; extra == "ent"
|
|
54
|
+
Requires-Dist: agstack[graph]; extra == "ent"
|
|
40
55
|
Dynamic: license-file
|
|
41
56
|
|
|
42
57
|
# AgStack
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Copyright (c) 2020-2026 XtraVisions, All rights reserved.
|
|
2
|
+
|
|
3
|
+
from .base import CacheBackend
|
|
4
|
+
from .memory import MemoryCacheBackend
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
__all__ = ["CacheBackend", "MemoryCacheBackend"]
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from .redis import RedisCacheBackend # noqa: F401
|
|
11
|
+
|
|
12
|
+
__all__.append("RedisCacheBackend")
|
|
13
|
+
except ImportError:
|
|
14
|
+
pass
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Copyright (c) 2020-2026 XtraVisions, All rights reserved.
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CacheBackend(ABC):
|
|
7
|
+
"""键值缓存后端抽象"""
|
|
8
|
+
|
|
9
|
+
@abstractmethod
|
|
10
|
+
async def get(self, key: str) -> bytes | None:
|
|
11
|
+
"""获取缓存值,不存在或已过期返回 None"""
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
async def set(self, key: str, value: bytes, ttl: int | None = None) -> None:
|
|
15
|
+
"""设置缓存值,ttl 单位为秒,None 表示永不过期"""
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
async def delete(self, key: str) -> None:
|
|
19
|
+
"""删除指定 key"""
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
async def delete_pattern(self, pattern: str) -> None:
|
|
23
|
+
"""删除匹配 glob 通配符的所有 key"""
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
async def exists(self, key: str) -> bool:
|
|
27
|
+
"""判断 key 是否存在且未过期"""
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
async def incr(self, key: str, ttl: int | None = None) -> int:
|
|
31
|
+
"""原子递增,不存在的 key 从 0 开始;如提供 ttl 且 key 新建则设置过期"""
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
async def expire(self, key: str, ttl: int) -> None:
|
|
35
|
+
"""为已有 key 设置过期时间(秒)"""
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
async def close(self) -> None:
|
|
39
|
+
"""释放连接资源"""
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Copyright (c) 2020-2026 XtraVisions, All rights reserved.
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from fnmatch import fnmatch
|
|
5
|
+
|
|
6
|
+
from .base import CacheBackend
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MemoryCacheBackend(CacheBackend):
|
|
10
|
+
"""基于内存的缓存后端实现"""
|
|
11
|
+
|
|
12
|
+
def __init__(self) -> None:
|
|
13
|
+
# key -> (value, 过期时间戳 | None)
|
|
14
|
+
self._store: dict[str, tuple[bytes, float | None]] = {}
|
|
15
|
+
|
|
16
|
+
def _is_expired(self, key: str) -> bool:
|
|
17
|
+
entry = self._store.get(key)
|
|
18
|
+
if entry is None:
|
|
19
|
+
return True
|
|
20
|
+
_, expires_at = entry
|
|
21
|
+
if expires_at is not None and time.monotonic() >= expires_at:
|
|
22
|
+
del self._store[key]
|
|
23
|
+
return True
|
|
24
|
+
return False
|
|
25
|
+
|
|
26
|
+
async def get(self, key: str) -> bytes | None:
|
|
27
|
+
if self._is_expired(key):
|
|
28
|
+
return None
|
|
29
|
+
return self._store[key][0]
|
|
30
|
+
|
|
31
|
+
async def set(self, key: str, value: bytes, ttl: int | None = None) -> None:
|
|
32
|
+
expires_at = time.monotonic() + ttl if ttl is not None else None
|
|
33
|
+
self._store[key] = (value, expires_at)
|
|
34
|
+
|
|
35
|
+
async def delete(self, key: str) -> None:
|
|
36
|
+
self._store.pop(key, None)
|
|
37
|
+
|
|
38
|
+
async def delete_pattern(self, pattern: str) -> None:
|
|
39
|
+
keys_to_delete = [k for k in self._store if fnmatch(k, pattern)]
|
|
40
|
+
for k in keys_to_delete:
|
|
41
|
+
del self._store[k]
|
|
42
|
+
|
|
43
|
+
async def exists(self, key: str) -> bool:
|
|
44
|
+
return not self._is_expired(key)
|
|
45
|
+
|
|
46
|
+
async def incr(self, key: str, ttl: int | None = None) -> int:
|
|
47
|
+
if self._is_expired(key):
|
|
48
|
+
# key 不存在,从 0 开始递增
|
|
49
|
+
value = 1
|
|
50
|
+
expires_at = time.monotonic() + ttl if ttl is not None else None
|
|
51
|
+
self._store[key] = (str(value).encode(), expires_at)
|
|
52
|
+
else:
|
|
53
|
+
current_bytes, expires_at = self._store[key]
|
|
54
|
+
value = int(current_bytes.decode()) + 1
|
|
55
|
+
# 已有 key 不修改过期时间
|
|
56
|
+
self._store[key] = (str(value).encode(), expires_at)
|
|
57
|
+
return value
|
|
58
|
+
|
|
59
|
+
async def expire(self, key: str, ttl: int) -> None:
|
|
60
|
+
entry = self._store.get(key)
|
|
61
|
+
if entry is not None:
|
|
62
|
+
value, _ = entry
|
|
63
|
+
self._store[key] = (value, time.monotonic() + ttl)
|
|
64
|
+
|
|
65
|
+
async def close(self) -> None:
|
|
66
|
+
pass
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Copyright (c) 2020-2026 XtraVisions, All rights reserved.
|
|
2
|
+
|
|
3
|
+
import redis.asyncio as aioredis
|
|
4
|
+
|
|
5
|
+
from .base import CacheBackend
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# INCR + 条件 EXPIRE 的 Lua 脚本,保证原子性
|
|
9
|
+
_INCR_WITH_TTL_SCRIPT = """
|
|
10
|
+
local v = redis.call('INCR', KEYS[1])
|
|
11
|
+
if v == 1 and ARGV[1] ~= '0' then
|
|
12
|
+
redis.call('EXPIRE', KEYS[1], ARGV[1])
|
|
13
|
+
end
|
|
14
|
+
return v
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RedisCacheBackend(CacheBackend):
|
|
19
|
+
"""基于 Redis 的缓存后端实现"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, client: aioredis.Redis) -> None:
|
|
22
|
+
self._client = client
|
|
23
|
+
self._incr_script = self._client.register_script(_INCR_WITH_TTL_SCRIPT)
|
|
24
|
+
|
|
25
|
+
async def get(self, key: str) -> bytes | None:
|
|
26
|
+
value = await self._client.get(key)
|
|
27
|
+
return value
|
|
28
|
+
|
|
29
|
+
async def set(self, key: str, value: bytes, ttl: int | None = None) -> None:
|
|
30
|
+
if ttl is not None:
|
|
31
|
+
await self._client.set(key, value, ex=ttl)
|
|
32
|
+
else:
|
|
33
|
+
await self._client.set(key, value)
|
|
34
|
+
|
|
35
|
+
async def delete(self, key: str) -> None:
|
|
36
|
+
await self._client.delete(key)
|
|
37
|
+
|
|
38
|
+
async def delete_pattern(self, pattern: str) -> None:
|
|
39
|
+
cursor: int = 0
|
|
40
|
+
while True:
|
|
41
|
+
cursor, keys = await self._client.scan(cursor=cursor, match=pattern, count=100)
|
|
42
|
+
if keys:
|
|
43
|
+
await self._client.delete(*keys)
|
|
44
|
+
if cursor == 0:
|
|
45
|
+
break
|
|
46
|
+
|
|
47
|
+
async def exists(self, key: str) -> bool:
|
|
48
|
+
result = await self._client.exists(key)
|
|
49
|
+
return bool(result)
|
|
50
|
+
|
|
51
|
+
async def incr(self, key: str, ttl: int | None = None) -> int:
|
|
52
|
+
if ttl is not None:
|
|
53
|
+
result = await self._incr_script(keys=[key], args=[ttl])
|
|
54
|
+
return int(result)
|
|
55
|
+
else:
|
|
56
|
+
result = await self._client.incr(key)
|
|
57
|
+
return int(result)
|
|
58
|
+
|
|
59
|
+
async def expire(self, key: str, ttl: int) -> None:
|
|
60
|
+
await self._client.expire(key, ttl)
|
|
61
|
+
|
|
62
|
+
async def close(self) -> None:
|
|
63
|
+
await self._client.aclose()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Copyright (c) 2020-2026 XtraVisions, All rights reserved.
|
|
2
|
+
|
|
3
|
+
from .base import MessageBus, Subscription
|
|
4
|
+
from .memory import MemoryMessageBus
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
__all__ = ["MessageBus", "Subscription", "MemoryMessageBus"]
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from .redis import RedisMessageBus # noqa: F401
|
|
11
|
+
|
|
12
|
+
__all__.append("RedisMessageBus")
|
|
13
|
+
except ImportError:
|
|
14
|
+
pass
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Copyright (c) 2020-2026 XtraVisions, All rights reserved.
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from collections.abc import AsyncIterator
|
|
5
|
+
from typing import Self
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Subscription(ABC):
|
|
9
|
+
"""订阅句柄,支持 async context manager 和 async iteration"""
|
|
10
|
+
|
|
11
|
+
async def __aenter__(self) -> Self:
|
|
12
|
+
return self
|
|
13
|
+
|
|
14
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
15
|
+
await self.close()
|
|
16
|
+
|
|
17
|
+
def __aiter__(self) -> AsyncIterator[tuple[str, bytes]]:
|
|
18
|
+
return self
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
async def __anext__(self) -> tuple[str, bytes]:
|
|
22
|
+
"""返回 (channel, message),无消息时阻塞等待"""
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
async def close(self) -> None:
|
|
26
|
+
"""取消订阅并释放资源"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class MessageBus(ABC):
|
|
30
|
+
"""发布/订阅消息总线"""
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
async def publish(self, channel: str, message: bytes) -> None:
|
|
34
|
+
"""发布消息到指定 channel,无订阅者时消息丢弃"""
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
def subscribe(self, *channels: str) -> Subscription:
|
|
38
|
+
"""订阅一个或多个 channel,返回 Subscription 句柄"""
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
async def close(self) -> None:
|
|
42
|
+
"""释放连接资源"""
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Copyright (c) 2020-2026 XtraVisions, All rights reserved.
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import AsyncIterator
|
|
5
|
+
|
|
6
|
+
from .base import MessageBus, Subscription
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MemorySubscription(Subscription):
|
|
10
|
+
"""内存消息总线的订阅实现"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, bus: "MemoryMessageBus", channels: tuple[str, ...]) -> None:
|
|
13
|
+
self._bus = bus
|
|
14
|
+
self._channels = channels
|
|
15
|
+
self._queue: asyncio.Queue[tuple[str, bytes]] = asyncio.Queue()
|
|
16
|
+
# 注册到 bus
|
|
17
|
+
for ch in self._channels:
|
|
18
|
+
self._bus._subscribers.setdefault(ch, []).append(self._queue)
|
|
19
|
+
|
|
20
|
+
def __aiter__(self) -> AsyncIterator[tuple[str, bytes]]:
|
|
21
|
+
return self
|
|
22
|
+
|
|
23
|
+
async def __anext__(self) -> tuple[str, bytes]:
|
|
24
|
+
try:
|
|
25
|
+
return await self._queue.get()
|
|
26
|
+
except asyncio.CancelledError:
|
|
27
|
+
raise StopAsyncIteration from None
|
|
28
|
+
|
|
29
|
+
async def close(self) -> None:
|
|
30
|
+
for ch in self._channels:
|
|
31
|
+
queues = self._bus._subscribers.get(ch)
|
|
32
|
+
if queues and self._queue in queues:
|
|
33
|
+
queues.remove(self._queue)
|
|
34
|
+
if not queues:
|
|
35
|
+
del self._bus._subscribers[ch]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MemoryMessageBus(MessageBus):
|
|
39
|
+
"""基于内存的发布/订阅消息总线实现"""
|
|
40
|
+
|
|
41
|
+
def __init__(self) -> None:
|
|
42
|
+
self._subscribers: dict[str, list[asyncio.Queue[tuple[str, bytes]]]] = {}
|
|
43
|
+
|
|
44
|
+
async def publish(self, channel: str, message: bytes) -> None:
|
|
45
|
+
queues = self._subscribers.get(channel)
|
|
46
|
+
if queues:
|
|
47
|
+
for queue in queues:
|
|
48
|
+
queue.put_nowait((channel, message))
|
|
49
|
+
|
|
50
|
+
def subscribe(self, *channels: str) -> Subscription:
|
|
51
|
+
return MemorySubscription(self, channels)
|
|
52
|
+
|
|
53
|
+
async def close(self) -> None:
|
|
54
|
+
self._subscribers.clear()
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Copyright (c) 2020-2026 XtraVisions, All rights reserved.
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import AsyncIterator
|
|
5
|
+
from typing import Self
|
|
6
|
+
|
|
7
|
+
import redis.asyncio as aioredis
|
|
8
|
+
from redis.asyncio.client import PubSub
|
|
9
|
+
|
|
10
|
+
from .base import MessageBus, Subscription
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RedisSubscription(Subscription):
|
|
14
|
+
"""Redis Pub/Sub 的订阅实现"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, pubsub: PubSub, channels: tuple[str, ...]) -> None:
|
|
17
|
+
self._pubsub = pubsub
|
|
18
|
+
self._channels = channels
|
|
19
|
+
self._task: asyncio.Task[None] | None = None
|
|
20
|
+
self._queue: asyncio.Queue[tuple[str, bytes]] = asyncio.Queue()
|
|
21
|
+
self._started = False
|
|
22
|
+
self._closed = False
|
|
23
|
+
|
|
24
|
+
async def _start(self) -> None:
|
|
25
|
+
if self._started:
|
|
26
|
+
return
|
|
27
|
+
self._started = True
|
|
28
|
+
await self._pubsub.subscribe(*self._channels)
|
|
29
|
+
self._task = asyncio.create_task(self._listen())
|
|
30
|
+
|
|
31
|
+
async def __aenter__(self) -> Self:
|
|
32
|
+
await self._start()
|
|
33
|
+
return self
|
|
34
|
+
|
|
35
|
+
async def _listen(self) -> None:
|
|
36
|
+
try:
|
|
37
|
+
async for msg in self._pubsub.listen():
|
|
38
|
+
if self._closed:
|
|
39
|
+
break
|
|
40
|
+
if msg["type"] == "message":
|
|
41
|
+
channel = msg["channel"]
|
|
42
|
+
if isinstance(channel, bytes):
|
|
43
|
+
channel = channel.decode()
|
|
44
|
+
data = msg["data"]
|
|
45
|
+
if isinstance(data, str):
|
|
46
|
+
data = data.encode()
|
|
47
|
+
self._queue.put_nowait((channel, data))
|
|
48
|
+
except asyncio.CancelledError:
|
|
49
|
+
pass
|
|
50
|
+
except Exception:
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
def __aiter__(self) -> AsyncIterator[tuple[str, bytes]]:
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
async def __anext__(self) -> tuple[str, bytes]:
|
|
57
|
+
if not self._started:
|
|
58
|
+
await self._start()
|
|
59
|
+
if self._closed and self._queue.empty():
|
|
60
|
+
raise StopAsyncIteration
|
|
61
|
+
try:
|
|
62
|
+
return await self._queue.get()
|
|
63
|
+
except asyncio.CancelledError:
|
|
64
|
+
raise StopAsyncIteration from None
|
|
65
|
+
|
|
66
|
+
async def close(self) -> None:
|
|
67
|
+
if self._closed:
|
|
68
|
+
return
|
|
69
|
+
self._closed = True
|
|
70
|
+
if self._task and not self._task.done():
|
|
71
|
+
self._task.cancel()
|
|
72
|
+
try:
|
|
73
|
+
await self._task
|
|
74
|
+
except asyncio.CancelledError:
|
|
75
|
+
pass
|
|
76
|
+
await self._pubsub.unsubscribe(*self._channels)
|
|
77
|
+
await self._pubsub.aclose()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class RedisMessageBus(MessageBus):
|
|
81
|
+
"""基于 Redis Pub/Sub 的消息总线实现"""
|
|
82
|
+
|
|
83
|
+
def __init__(self, client: aioredis.Redis) -> None:
|
|
84
|
+
self._client = client
|
|
85
|
+
|
|
86
|
+
async def publish(self, channel: str, message: bytes) -> None:
|
|
87
|
+
await self._client.publish(channel, message)
|
|
88
|
+
|
|
89
|
+
def subscribe(self, *channels: str) -> RedisSubscription:
|
|
90
|
+
pubsub = self._client.pubsub()
|
|
91
|
+
return RedisSubscription(pubsub, channels)
|
|
92
|
+
|
|
93
|
+
async def close(self) -> None:
|
|
94
|
+
await self._client.aclose()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agstack
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.17.1
|
|
4
4
|
Summary: Production-ready toolkit for building FastAPI and LLM applications
|
|
5
5
|
Author-email: XtraVisions <gitadmin@xtravisions.com>, Chen Hao <chenhao@xtravisions.com>
|
|
6
6
|
Maintainer-email: XtraVisions <gitadmin@xtravisions.com>, Chen Hao <chenhao@xtravisions.com>
|
|
@@ -20,13 +20,9 @@ Classifier: Typing :: Typed
|
|
|
20
20
|
Requires-Python: >=3.12
|
|
21
21
|
Description-Content-Type: text/markdown
|
|
22
22
|
License-File: LICENSE
|
|
23
|
-
Requires-Dist: aio-pika>=9.6.2
|
|
24
|
-
Requires-Dist: asyncpg>=0.30.0
|
|
25
|
-
Requires-Dist: elasticsearch[async]>=9.3.0
|
|
26
23
|
Requires-Dist: fastapi>=0.136.1
|
|
27
24
|
Requires-Dist: jwcrypto>=1.5.7
|
|
28
25
|
Requires-Dist: loguru>=0.7.3
|
|
29
|
-
Requires-Dist: nebula3-python>=3.8.3
|
|
30
26
|
Requires-Dist: openai>=2.34.0
|
|
31
27
|
Requires-Dist: bcrypt>=4.0.0
|
|
32
28
|
Requires-Dist: pycasbin>=2.8.0
|
|
@@ -37,6 +33,25 @@ Requires-Dist: RestrictedPython>=7.0
|
|
|
37
33
|
Requires-Dist: sqlobjects>=1.9.1
|
|
38
34
|
Requires-Dist: tiktoken>=0.12.0
|
|
39
35
|
Requires-Dist: uvicorn>=0.46.0
|
|
36
|
+
Provides-Extra: mq
|
|
37
|
+
Requires-Dist: aio-pika>=9.6.2; extra == "mq"
|
|
38
|
+
Provides-Extra: db
|
|
39
|
+
Requires-Dist: asyncpg>=0.30.0; extra == "db"
|
|
40
|
+
Provides-Extra: es
|
|
41
|
+
Requires-Dist: elasticsearch[async]>=9.3.0; extra == "es"
|
|
42
|
+
Provides-Extra: graph
|
|
43
|
+
Requires-Dist: nebula3-python>=3.8.3; extra == "graph"
|
|
44
|
+
Provides-Extra: redis
|
|
45
|
+
Requires-Dist: redis>=5.0; extra == "redis"
|
|
46
|
+
Provides-Extra: std
|
|
47
|
+
Requires-Dist: agstack[mq]; extra == "std"
|
|
48
|
+
Requires-Dist: agstack[db]; extra == "std"
|
|
49
|
+
Provides-Extra: pro
|
|
50
|
+
Requires-Dist: agstack[std]; extra == "pro"
|
|
51
|
+
Requires-Dist: agstack[es]; extra == "pro"
|
|
52
|
+
Provides-Extra: ent
|
|
53
|
+
Requires-Dist: agstack[pro]; extra == "ent"
|
|
54
|
+
Requires-Dist: agstack[graph]; extra == "ent"
|
|
40
55
|
Dynamic: license-file
|
|
41
56
|
|
|
42
57
|
# AgStack
|
|
@@ -13,6 +13,10 @@ agstack.egg-info/SOURCES.txt
|
|
|
13
13
|
agstack.egg-info/dependency_links.txt
|
|
14
14
|
agstack.egg-info/requires.txt
|
|
15
15
|
agstack.egg-info/top_level.txt
|
|
16
|
+
agstack/cache/__init__.py
|
|
17
|
+
agstack/cache/base.py
|
|
18
|
+
agstack/cache/memory.py
|
|
19
|
+
agstack/cache/redis.py
|
|
16
20
|
agstack/config/__init__.py
|
|
17
21
|
agstack/config/logger.py
|
|
18
22
|
agstack/config/manager.py
|
|
@@ -55,8 +59,16 @@ agstack/llm/flow/nodes/python_node.py
|
|
|
55
59
|
agstack/llm/flow/nodes/subflow_node.py
|
|
56
60
|
agstack/llm/flow/nodes/switch_node.py
|
|
57
61
|
agstack/llm/flow/nodes/tool_node.py
|
|
62
|
+
agstack/messagebus/__init__.py
|
|
63
|
+
agstack/messagebus/base.py
|
|
64
|
+
agstack/messagebus/memory.py
|
|
65
|
+
agstack/messagebus/redis.py
|
|
58
66
|
agstack/security/__init__.py
|
|
59
67
|
agstack/security/casbin.py
|
|
60
68
|
agstack/security/crypt.py
|
|
69
|
+
tests/test_cache_memory.py
|
|
70
|
+
tests/test_cache_redis.py
|
|
61
71
|
tests/test_flow_io.py
|
|
62
|
-
tests/test_flow_switch_subflow.py
|
|
72
|
+
tests/test_flow_switch_subflow.py
|
|
73
|
+
tests/test_messagebus_memory.py
|
|
74
|
+
tests/test_messagebus_redis.py
|
|
@@ -1,10 +1,6 @@
|
|
|
1
|
-
aio-pika>=9.6.2
|
|
2
|
-
asyncpg>=0.30.0
|
|
3
|
-
elasticsearch[async]>=9.3.0
|
|
4
1
|
fastapi>=0.136.1
|
|
5
2
|
jwcrypto>=1.5.7
|
|
6
3
|
loguru>=0.7.3
|
|
7
|
-
nebula3-python>=3.8.3
|
|
8
4
|
openai>=2.34.0
|
|
9
5
|
bcrypt>=4.0.0
|
|
10
6
|
pycasbin>=2.8.0
|
|
@@ -15,3 +11,30 @@ RestrictedPython>=7.0
|
|
|
15
11
|
sqlobjects>=1.9.1
|
|
16
12
|
tiktoken>=0.12.0
|
|
17
13
|
uvicorn>=0.46.0
|
|
14
|
+
|
|
15
|
+
[db]
|
|
16
|
+
asyncpg>=0.30.0
|
|
17
|
+
|
|
18
|
+
[ent]
|
|
19
|
+
agstack[pro]
|
|
20
|
+
agstack[graph]
|
|
21
|
+
|
|
22
|
+
[es]
|
|
23
|
+
elasticsearch[async]>=9.3.0
|
|
24
|
+
|
|
25
|
+
[graph]
|
|
26
|
+
nebula3-python>=3.8.3
|
|
27
|
+
|
|
28
|
+
[mq]
|
|
29
|
+
aio-pika>=9.6.2
|
|
30
|
+
|
|
31
|
+
[pro]
|
|
32
|
+
agstack[std]
|
|
33
|
+
agstack[es]
|
|
34
|
+
|
|
35
|
+
[redis]
|
|
36
|
+
redis>=5.0
|
|
37
|
+
|
|
38
|
+
[std]
|
|
39
|
+
agstack[mq]
|
|
40
|
+
agstack[db]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "agstack"
|
|
3
|
-
version = "1.
|
|
3
|
+
version = "1.17.1"
|
|
4
4
|
description = "Production-ready toolkit for building FastAPI and LLM applications"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -39,13 +39,9 @@ classifiers = [
|
|
|
39
39
|
]
|
|
40
40
|
requires-python = ">=3.12"
|
|
41
41
|
dependencies = [
|
|
42
|
-
"aio-pika>=9.6.2",
|
|
43
|
-
"asyncpg>=0.30.0",
|
|
44
|
-
"elasticsearch[async]>=9.3.0",
|
|
45
42
|
"fastapi>=0.136.1",
|
|
46
43
|
"jwcrypto>=1.5.7",
|
|
47
44
|
"loguru>=0.7.3",
|
|
48
|
-
"nebula3-python>=3.8.3",
|
|
49
45
|
"openai>=2.34.0",
|
|
50
46
|
"bcrypt>=4.0.0",
|
|
51
47
|
"pycasbin>=2.8.0",
|
|
@@ -57,11 +53,23 @@ dependencies = [
|
|
|
57
53
|
"tiktoken>=0.12.0",
|
|
58
54
|
"uvicorn>=0.46.0",
|
|
59
55
|
]
|
|
56
|
+
|
|
57
|
+
[project.optional-dependencies]
|
|
58
|
+
mq = ["aio-pika>=9.6.2"]
|
|
59
|
+
db = ["asyncpg>=0.30.0"]
|
|
60
|
+
es = ["elasticsearch[async]>=9.3.0"]
|
|
61
|
+
graph = ["nebula3-python>=3.8.3"]
|
|
62
|
+
redis = ["redis>=5.0"]
|
|
63
|
+
std = ["agstack[mq]", "agstack[db]"]
|
|
64
|
+
pro = ["agstack[std]", "agstack[es]"]
|
|
65
|
+
ent = ["agstack[pro]", "agstack[graph]"]
|
|
66
|
+
|
|
60
67
|
[dependency-groups]
|
|
61
68
|
dev = [
|
|
62
69
|
"pre-commit>=4.6.0",
|
|
63
70
|
"pyright>=1.1.409",
|
|
64
71
|
"pytest>=9.0.3",
|
|
72
|
+
"pytest-asyncio>=1.0.0",
|
|
65
73
|
"ruff>=0.15.12",
|
|
66
74
|
"setuptools>=82.0.0",
|
|
67
75
|
]
|
|
@@ -72,7 +80,7 @@ dev = [
|
|
|
72
80
|
#default = true
|
|
73
81
|
|
|
74
82
|
[tool.pyright]
|
|
75
|
-
include = ["app", "depends"]
|
|
83
|
+
include = ["app", "depends", "agstack/cache", "agstack/messagebus", "tests"]
|
|
76
84
|
exclude = ["**/__pycache__"]
|
|
77
85
|
pythonVersion = "3.12"
|
|
78
86
|
reportMissingImports = "error"
|
|
@@ -119,3 +127,8 @@ tag_format = "$version"
|
|
|
119
127
|
version_scheme = "pep440"
|
|
120
128
|
version_provider = "uv"
|
|
121
129
|
update_changelog_on_bump = true
|
|
130
|
+
|
|
131
|
+
[tool.pytest.ini_options]
|
|
132
|
+
markers = [
|
|
133
|
+
"redis: 需要真实 Redis 连接的测试",
|
|
134
|
+
]
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# Copyright (c) 2020-2026 XtraVisions, All rights reserved.
|
|
2
|
+
|
|
3
|
+
"""MemoryCacheBackend 全接口测试"""
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from unittest.mock import patch
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from agstack.cache import MemoryCacheBackend
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture
|
|
14
|
+
def cache() -> MemoryCacheBackend:
|
|
15
|
+
return MemoryCacheBackend()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TestGetSet:
|
|
19
|
+
@pytest.mark.asyncio
|
|
20
|
+
async def test_set_and_get(self, cache: MemoryCacheBackend) -> None:
|
|
21
|
+
await cache.set("key1", b"value1")
|
|
22
|
+
assert await cache.get("key1") == b"value1"
|
|
23
|
+
|
|
24
|
+
@pytest.mark.asyncio
|
|
25
|
+
async def test_get_nonexistent(self, cache: MemoryCacheBackend) -> None:
|
|
26
|
+
assert await cache.get("missing") is None
|
|
27
|
+
|
|
28
|
+
@pytest.mark.asyncio
|
|
29
|
+
async def test_set_overwrite(self, cache: MemoryCacheBackend) -> None:
|
|
30
|
+
await cache.set("key1", b"v1")
|
|
31
|
+
await cache.set("key1", b"v2")
|
|
32
|
+
assert await cache.get("key1") == b"v2"
|
|
33
|
+
|
|
34
|
+
@pytest.mark.asyncio
|
|
35
|
+
async def test_set_with_ttl_not_expired(self, cache: MemoryCacheBackend) -> None:
|
|
36
|
+
await cache.set("key1", b"value1", ttl=10)
|
|
37
|
+
assert await cache.get("key1") == b"value1"
|
|
38
|
+
|
|
39
|
+
@pytest.mark.asyncio
|
|
40
|
+
async def test_set_with_ttl_expired(self, cache: MemoryCacheBackend) -> None:
|
|
41
|
+
await cache.set("key1", b"value1", ttl=1)
|
|
42
|
+
# 模拟时间流逝
|
|
43
|
+
with patch("agstack.cache.memory.time.monotonic", return_value=time.monotonic() + 2):
|
|
44
|
+
assert await cache.get("key1") is None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TestDelete:
|
|
48
|
+
@pytest.mark.asyncio
|
|
49
|
+
async def test_delete_existing(self, cache: MemoryCacheBackend) -> None:
|
|
50
|
+
await cache.set("key1", b"value1")
|
|
51
|
+
await cache.delete("key1")
|
|
52
|
+
assert await cache.get("key1") is None
|
|
53
|
+
|
|
54
|
+
@pytest.mark.asyncio
|
|
55
|
+
async def test_delete_nonexistent(self, cache: MemoryCacheBackend) -> None:
|
|
56
|
+
await cache.delete("missing") # 不应抛异常
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TestDeletePattern:
|
|
60
|
+
@pytest.mark.asyncio
|
|
61
|
+
async def test_delete_pattern_wildcard(self, cache: MemoryCacheBackend) -> None:
|
|
62
|
+
await cache.set("user:1:name", b"alice")
|
|
63
|
+
await cache.set("user:2:name", b"bob")
|
|
64
|
+
await cache.set("session:1", b"data")
|
|
65
|
+
await cache.delete_pattern("user:*")
|
|
66
|
+
assert await cache.get("user:1:name") is None
|
|
67
|
+
assert await cache.get("user:2:name") is None
|
|
68
|
+
assert await cache.get("session:1") == b"data"
|
|
69
|
+
|
|
70
|
+
@pytest.mark.asyncio
|
|
71
|
+
async def test_delete_pattern_question_mark(self, cache: MemoryCacheBackend) -> None:
|
|
72
|
+
await cache.set("a1", b"v1")
|
|
73
|
+
await cache.set("a2", b"v2")
|
|
74
|
+
await cache.set("ab", b"v3")
|
|
75
|
+
await cache.delete_pattern("a?")
|
|
76
|
+
assert await cache.get("a1") is None
|
|
77
|
+
assert await cache.get("a2") is None
|
|
78
|
+
assert await cache.get("ab") is None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class TestExists:
|
|
82
|
+
@pytest.mark.asyncio
|
|
83
|
+
async def test_exists_true(self, cache: MemoryCacheBackend) -> None:
|
|
84
|
+
await cache.set("key1", b"value1")
|
|
85
|
+
assert await cache.exists("key1") is True
|
|
86
|
+
|
|
87
|
+
@pytest.mark.asyncio
|
|
88
|
+
async def test_exists_false(self, cache: MemoryCacheBackend) -> None:
|
|
89
|
+
assert await cache.exists("missing") is False
|
|
90
|
+
|
|
91
|
+
@pytest.mark.asyncio
|
|
92
|
+
async def test_exists_expired(self, cache: MemoryCacheBackend) -> None:
|
|
93
|
+
await cache.set("key1", b"value1", ttl=1)
|
|
94
|
+
with patch("agstack.cache.memory.time.monotonic", return_value=time.monotonic() + 2):
|
|
95
|
+
assert await cache.exists("key1") is False
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class TestIncr:
|
|
99
|
+
@pytest.mark.asyncio
|
|
100
|
+
async def test_incr_new_key(self, cache: MemoryCacheBackend) -> None:
|
|
101
|
+
result = await cache.incr("counter")
|
|
102
|
+
assert result == 1
|
|
103
|
+
assert await cache.get("counter") == b"1"
|
|
104
|
+
|
|
105
|
+
@pytest.mark.asyncio
|
|
106
|
+
async def test_incr_existing_key(self, cache: MemoryCacheBackend) -> None:
|
|
107
|
+
await cache.set("counter", b"5")
|
|
108
|
+
result = await cache.incr("counter")
|
|
109
|
+
assert result == 6
|
|
110
|
+
assert await cache.get("counter") == b"6"
|
|
111
|
+
|
|
112
|
+
@pytest.mark.asyncio
|
|
113
|
+
async def test_incr_with_ttl_new_key(self, cache: MemoryCacheBackend) -> None:
|
|
114
|
+
result = await cache.incr("counter", ttl=10)
|
|
115
|
+
assert result == 1
|
|
116
|
+
# 验证 ttl 已设置(过期后取不到)
|
|
117
|
+
with patch("agstack.cache.memory.time.monotonic", return_value=time.monotonic() + 11):
|
|
118
|
+
assert await cache.get("counter") is None
|
|
119
|
+
|
|
120
|
+
@pytest.mark.asyncio
|
|
121
|
+
async def test_incr_with_ttl_existing_key(self, cache: MemoryCacheBackend) -> None:
|
|
122
|
+
await cache.set("counter", b"3", ttl=100)
|
|
123
|
+
result = await cache.incr("counter", ttl=5)
|
|
124
|
+
assert result == 4
|
|
125
|
+
# 已有 key 不修改过期时间,原 ttl 仍有效
|
|
126
|
+
assert await cache.get("counter") == b"4"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class TestExpire:
|
|
130
|
+
@pytest.mark.asyncio
|
|
131
|
+
async def test_expire_sets_ttl(self, cache: MemoryCacheBackend) -> None:
|
|
132
|
+
await cache.set("key1", b"value1")
|
|
133
|
+
await cache.expire("key1", 1)
|
|
134
|
+
with patch("agstack.cache.memory.time.monotonic", return_value=time.monotonic() + 2):
|
|
135
|
+
assert await cache.get("key1") is None
|
|
136
|
+
|
|
137
|
+
@pytest.mark.asyncio
|
|
138
|
+
async def test_expire_nonexistent_key(self, cache: MemoryCacheBackend) -> None:
|
|
139
|
+
await cache.expire("missing", 10) # 不应抛异常
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class TestClose:
|
|
143
|
+
@pytest.mark.asyncio
|
|
144
|
+
async def test_close(self, cache: MemoryCacheBackend) -> None:
|
|
145
|
+
await cache.close() # 空操作,不应抛异常
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Copyright (c) 2020-2026 XtraVisions, All rights reserved.
|
|
2
|
+
|
|
3
|
+
"""RedisCacheBackend 测试(需要真实 Redis)"""
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
import redis.asyncio as aioredis
|
|
7
|
+
|
|
8
|
+
from agstack.cache import CacheBackend, RedisCacheBackend
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
pytestmark = [
|
|
12
|
+
pytest.mark.redis,
|
|
13
|
+
pytest.mark.asyncio,
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
async def cache():
|
|
19
|
+
client = aioredis.from_url("redis://localhost:6379/15")
|
|
20
|
+
await client.flushdb()
|
|
21
|
+
backend = RedisCacheBackend(client)
|
|
22
|
+
yield backend
|
|
23
|
+
await client.flushdb()
|
|
24
|
+
await backend.close()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TestGetSet:
|
|
28
|
+
async def test_set_and_get(self, cache: CacheBackend) -> None:
|
|
29
|
+
await cache.set("key1", b"value1")
|
|
30
|
+
assert await cache.get("key1") == b"value1"
|
|
31
|
+
|
|
32
|
+
async def test_get_nonexistent(self, cache: CacheBackend) -> None:
|
|
33
|
+
assert await cache.get("missing") is None
|
|
34
|
+
|
|
35
|
+
async def test_set_overwrite(self, cache: CacheBackend) -> None:
|
|
36
|
+
await cache.set("key1", b"v1")
|
|
37
|
+
await cache.set("key1", b"v2")
|
|
38
|
+
assert await cache.get("key1") == b"v2"
|
|
39
|
+
|
|
40
|
+
async def test_set_with_ttl(self, cache: CacheBackend) -> None:
|
|
41
|
+
await cache.set("key1", b"value1", ttl=10)
|
|
42
|
+
assert await cache.get("key1") == b"value1"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TestDelete:
|
|
46
|
+
async def test_delete_existing(self, cache: CacheBackend) -> None:
|
|
47
|
+
await cache.set("key1", b"value1")
|
|
48
|
+
await cache.delete("key1")
|
|
49
|
+
assert await cache.get("key1") is None
|
|
50
|
+
|
|
51
|
+
async def test_delete_nonexistent(self, cache: CacheBackend) -> None:
|
|
52
|
+
await cache.delete("missing")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TestDeletePattern:
|
|
56
|
+
async def test_delete_pattern(self, cache: CacheBackend) -> None:
|
|
57
|
+
await cache.set("user:1:name", b"alice")
|
|
58
|
+
await cache.set("user:2:name", b"bob")
|
|
59
|
+
await cache.set("session:1", b"data")
|
|
60
|
+
await cache.delete_pattern("user:*")
|
|
61
|
+
assert await cache.get("user:1:name") is None
|
|
62
|
+
assert await cache.get("user:2:name") is None
|
|
63
|
+
assert await cache.get("session:1") == b"data"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TestExists:
|
|
67
|
+
async def test_exists_true(self, cache: CacheBackend) -> None:
|
|
68
|
+
await cache.set("key1", b"value1")
|
|
69
|
+
assert await cache.exists("key1") is True
|
|
70
|
+
|
|
71
|
+
async def test_exists_false(self, cache: CacheBackend) -> None:
|
|
72
|
+
assert await cache.exists("missing") is False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TestIncr:
|
|
76
|
+
async def test_incr_new_key(self, cache: CacheBackend) -> None:
|
|
77
|
+
result = await cache.incr("counter")
|
|
78
|
+
assert result == 1
|
|
79
|
+
|
|
80
|
+
async def test_incr_existing_key(self, cache: CacheBackend) -> None:
|
|
81
|
+
await cache.set("counter", b"5")
|
|
82
|
+
result = await cache.incr("counter")
|
|
83
|
+
assert result == 6
|
|
84
|
+
|
|
85
|
+
async def test_incr_with_ttl(self, cache: CacheBackend) -> None:
|
|
86
|
+
result = await cache.incr("counter", ttl=60)
|
|
87
|
+
assert result == 1
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class TestExpire:
|
|
91
|
+
async def test_expire_sets_ttl(self, cache: CacheBackend) -> None:
|
|
92
|
+
await cache.set("key1", b"value1")
|
|
93
|
+
await cache.expire("key1", 60)
|
|
94
|
+
assert await cache.exists("key1") is True
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class TestClose:
|
|
98
|
+
async def test_close(self) -> None:
|
|
99
|
+
client = aioredis.from_url("redis://localhost:6379/15")
|
|
100
|
+
backend = RedisCacheBackend(client)
|
|
101
|
+
await backend.close()
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Copyright (c) 2020-2026 XtraVisions, All rights reserved.
|
|
2
|
+
|
|
3
|
+
"""MemoryMessageBus 全接口测试"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from agstack.messagebus import MemoryMessageBus
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def bus() -> MemoryMessageBus:
|
|
14
|
+
return MemoryMessageBus()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TestPublishSubscribe:
|
|
18
|
+
@pytest.mark.asyncio
|
|
19
|
+
async def test_single_channel(self, bus: MemoryMessageBus) -> None:
|
|
20
|
+
sub = bus.subscribe("ch1")
|
|
21
|
+
async with sub:
|
|
22
|
+
await bus.publish("ch1", b"hello")
|
|
23
|
+
channel, message = await asyncio.wait_for(sub.__anext__(), timeout=1)
|
|
24
|
+
assert channel == "ch1"
|
|
25
|
+
assert message == b"hello"
|
|
26
|
+
|
|
27
|
+
@pytest.mark.asyncio
|
|
28
|
+
async def test_multi_channel(self, bus: MemoryMessageBus) -> None:
|
|
29
|
+
sub = bus.subscribe("ch1", "ch2")
|
|
30
|
+
async with sub:
|
|
31
|
+
await bus.publish("ch1", b"msg1")
|
|
32
|
+
await bus.publish("ch2", b"msg2")
|
|
33
|
+
results = []
|
|
34
|
+
for _ in range(2):
|
|
35
|
+
ch, msg = await asyncio.wait_for(sub.__anext__(), timeout=1)
|
|
36
|
+
results.append((ch, msg))
|
|
37
|
+
assert ("ch1", b"msg1") in results
|
|
38
|
+
assert ("ch2", b"msg2") in results
|
|
39
|
+
|
|
40
|
+
@pytest.mark.asyncio
|
|
41
|
+
async def test_multiple_subscribers(self, bus: MemoryMessageBus) -> None:
|
|
42
|
+
sub1 = bus.subscribe("ch1")
|
|
43
|
+
sub2 = bus.subscribe("ch1")
|
|
44
|
+
async with sub1, sub2:
|
|
45
|
+
await bus.publish("ch1", b"broadcast")
|
|
46
|
+
ch1, msg1 = await asyncio.wait_for(sub1.__anext__(), timeout=1)
|
|
47
|
+
ch2, msg2 = await asyncio.wait_for(sub2.__anext__(), timeout=1)
|
|
48
|
+
assert msg1 == b"broadcast"
|
|
49
|
+
assert msg2 == b"broadcast"
|
|
50
|
+
|
|
51
|
+
@pytest.mark.asyncio
|
|
52
|
+
async def test_publish_no_subscribers(self, bus: MemoryMessageBus) -> None:
|
|
53
|
+
# 无订阅者时 publish 不应抛异常
|
|
54
|
+
await bus.publish("ch1", b"nobody listening")
|
|
55
|
+
|
|
56
|
+
@pytest.mark.asyncio
|
|
57
|
+
async def test_subscription_close_removes_queue(self, bus: MemoryMessageBus) -> None:
|
|
58
|
+
sub = bus.subscribe("ch1")
|
|
59
|
+
async with sub:
|
|
60
|
+
pass
|
|
61
|
+
# close 后不应再收到消息
|
|
62
|
+
assert "ch1" not in bus._subscribers or len(bus._subscribers.get("ch1", [])) == 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TestClose:
|
|
66
|
+
@pytest.mark.asyncio
|
|
67
|
+
async def test_bus_close_clears_subscribers(self, bus: MemoryMessageBus) -> None:
|
|
68
|
+
bus.subscribe("ch1")
|
|
69
|
+
bus.subscribe("ch2")
|
|
70
|
+
await bus.close()
|
|
71
|
+
assert len(bus._subscribers) == 0
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Copyright (c) 2020-2026 XtraVisions, All rights reserved.
|
|
2
|
+
|
|
3
|
+
"""RedisMessageBus 测试(需要真实 Redis)"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
import redis.asyncio as aioredis
|
|
9
|
+
|
|
10
|
+
from agstack.messagebus.redis import RedisMessageBus
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
pytestmark = [
|
|
14
|
+
pytest.mark.redis,
|
|
15
|
+
pytest.mark.asyncio,
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture
|
|
20
|
+
async def bus():
|
|
21
|
+
client = aioredis.from_url("redis://localhost:6379/15")
|
|
22
|
+
mb = RedisMessageBus(client)
|
|
23
|
+
yield mb
|
|
24
|
+
await mb.close()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TestPublishSubscribe:
|
|
28
|
+
async def test_single_channel(self, bus: RedisMessageBus) -> None:
|
|
29
|
+
async with bus.subscribe("test:ch1") as sub:
|
|
30
|
+
await asyncio.sleep(0.1)
|
|
31
|
+
await bus.publish("test:ch1", b"hello")
|
|
32
|
+
channel, message = await asyncio.wait_for(anext(sub), timeout=3)
|
|
33
|
+
assert channel == "test:ch1"
|
|
34
|
+
assert message == b"hello"
|
|
35
|
+
|
|
36
|
+
async def test_multi_channel(self, bus: RedisMessageBus) -> None:
|
|
37
|
+
async with bus.subscribe("test:ch1", "test:ch2") as sub:
|
|
38
|
+
await asyncio.sleep(0.1)
|
|
39
|
+
await bus.publish("test:ch1", b"msg1")
|
|
40
|
+
await bus.publish("test:ch2", b"msg2")
|
|
41
|
+
results = []
|
|
42
|
+
for _ in range(2):
|
|
43
|
+
ch, msg = await asyncio.wait_for(anext(sub), timeout=3)
|
|
44
|
+
results.append((ch, msg))
|
|
45
|
+
channels = [r[0] for r in results]
|
|
46
|
+
assert "test:ch1" in channels
|
|
47
|
+
assert "test:ch2" in channels
|
|
48
|
+
|
|
49
|
+
async def test_lazy_start_without_context_manager(self, bus: RedisMessageBus) -> None:
|
|
50
|
+
sub = bus.subscribe("test:ch1")
|
|
51
|
+
await asyncio.sleep(0.1)
|
|
52
|
+
await bus.publish("test:ch1", b"world")
|
|
53
|
+
channel, message = await asyncio.wait_for(anext(sub), timeout=3)
|
|
54
|
+
assert channel == "test:ch1"
|
|
55
|
+
assert message == b"world"
|
|
56
|
+
await sub.close()
|
|
57
|
+
|
|
58
|
+
async def test_publish_no_subscribers(self, bus: RedisMessageBus) -> None:
|
|
59
|
+
await bus.publish("test:nobody", b"lost message")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TestSubscriptionClose:
|
|
63
|
+
async def test_close(self, bus: RedisMessageBus) -> None:
|
|
64
|
+
async with bus.subscribe("test:ch1") as sub:
|
|
65
|
+
await asyncio.sleep(0.1)
|
|
66
|
+
assert sub._closed
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TestBusClose:
|
|
70
|
+
async def test_close(self) -> None:
|
|
71
|
+
client = aioredis.from_url("redis://localhost:6379/15")
|
|
72
|
+
mb = RedisMessageBus(client)
|
|
73
|
+
await mb.close()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|