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.
Files changed (76) hide show
  1. {agstack-1.16.2 → agstack-1.17.1}/PKG-INFO +20 -5
  2. agstack-1.17.1/agstack/cache/__init__.py +14 -0
  3. agstack-1.17.1/agstack/cache/base.py +39 -0
  4. agstack-1.17.1/agstack/cache/memory.py +66 -0
  5. agstack-1.17.1/agstack/cache/redis.py +63 -0
  6. agstack-1.17.1/agstack/messagebus/__init__.py +14 -0
  7. agstack-1.17.1/agstack/messagebus/base.py +42 -0
  8. agstack-1.17.1/agstack/messagebus/memory.py +54 -0
  9. agstack-1.17.1/agstack/messagebus/redis.py +94 -0
  10. {agstack-1.16.2 → agstack-1.17.1}/agstack.egg-info/PKG-INFO +20 -5
  11. {agstack-1.16.2 → agstack-1.17.1}/agstack.egg-info/SOURCES.txt +13 -1
  12. {agstack-1.16.2 → agstack-1.17.1}/agstack.egg-info/requires.txt +27 -4
  13. {agstack-1.16.2 → agstack-1.17.1}/pyproject.toml +19 -6
  14. agstack-1.17.1/tests/test_cache_memory.py +145 -0
  15. agstack-1.17.1/tests/test_cache_redis.py +101 -0
  16. agstack-1.17.1/tests/test_messagebus_memory.py +71 -0
  17. agstack-1.17.1/tests/test_messagebus_redis.py +73 -0
  18. {agstack-1.16.2 → agstack-1.17.1}/LICENSE +0 -0
  19. {agstack-1.16.2 → agstack-1.17.1}/README.md +0 -0
  20. {agstack-1.16.2 → agstack-1.17.1}/agstack/__init__.py +0 -0
  21. {agstack-1.16.2 → agstack-1.17.1}/agstack/config/__init__.py +0 -0
  22. {agstack-1.16.2 → agstack-1.17.1}/agstack/config/logger.py +0 -0
  23. {agstack-1.16.2 → agstack-1.17.1}/agstack/config/manager.py +0 -0
  24. {agstack-1.16.2 → agstack-1.17.1}/agstack/config/types.py +0 -0
  25. {agstack-1.16.2 → agstack-1.17.1}/agstack/contexts.py +0 -0
  26. {agstack-1.16.2 → agstack-1.17.1}/agstack/decorators.py +0 -0
  27. {agstack-1.16.2 → agstack-1.17.1}/agstack/events.py +0 -0
  28. {agstack-1.16.2 → agstack-1.17.1}/agstack/exceptions.py +0 -0
  29. {agstack-1.16.2 → agstack-1.17.1}/agstack/fastapi/__init__.py +0 -0
  30. {agstack-1.16.2 → agstack-1.17.1}/agstack/fastapi/exception.py +0 -0
  31. {agstack-1.16.2 → agstack-1.17.1}/agstack/fastapi/middleware.py +0 -0
  32. {agstack-1.16.2 → agstack-1.17.1}/agstack/fastapi/offline.py +0 -0
  33. {agstack-1.16.2 → agstack-1.17.1}/agstack/fastapi/sse.py +0 -0
  34. {agstack-1.16.2 → agstack-1.17.1}/agstack/infra/db/__init__.py +0 -0
  35. {agstack-1.16.2 → agstack-1.17.1}/agstack/infra/es/__init__.py +0 -0
  36. {agstack-1.16.2 → agstack-1.17.1}/agstack/infra/kg/__init__.py +0 -0
  37. {agstack-1.16.2 → agstack-1.17.1}/agstack/infra/mq/__init__.py +0 -0
  38. {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/__init__.py +0 -0
  39. {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/client.py +0 -0
  40. {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/__init__.py +0 -0
  41. {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/agent.py +0 -0
  42. {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/context.py +0 -0
  43. {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/event.py +0 -0
  44. {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/exceptions.py +0 -0
  45. {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/factory.py +0 -0
  46. {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/flow.py +0 -0
  47. {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/loader.py +0 -0
  48. {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/nodes/__init__.py +0 -0
  49. {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/nodes/agent_node.py +0 -0
  50. {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/nodes/base.py +0 -0
  51. {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/nodes/detect_node.py +0 -0
  52. {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/nodes/echo_node.py +0 -0
  53. {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/nodes/llm_chat_node.py +0 -0
  54. {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/nodes/llm_embed_node.py +0 -0
  55. {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/nodes/llm_rerank_node.py +0 -0
  56. {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/nodes/python_node.py +0 -0
  57. {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/nodes/subflow_node.py +0 -0
  58. {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/nodes/switch_node.py +0 -0
  59. {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/nodes/tool_node.py +0 -0
  60. {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/records.py +0 -0
  61. {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/registry.py +0 -0
  62. {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/sandbox.py +0 -0
  63. {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/state.py +0 -0
  64. {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/flow/tool.py +0 -0
  65. {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/prompts.py +0 -0
  66. {agstack-1.16.2 → agstack-1.17.1}/agstack/llm/token.py +0 -0
  67. {agstack-1.16.2 → agstack-1.17.1}/agstack/schema.py +0 -0
  68. {agstack-1.16.2 → agstack-1.17.1}/agstack/security/__init__.py +0 -0
  69. {agstack-1.16.2 → agstack-1.17.1}/agstack/security/casbin.py +0 -0
  70. {agstack-1.16.2 → agstack-1.17.1}/agstack/security/crypt.py +0 -0
  71. {agstack-1.16.2 → agstack-1.17.1}/agstack/status.py +0 -0
  72. {agstack-1.16.2 → agstack-1.17.1}/agstack.egg-info/dependency_links.txt +0 -0
  73. {agstack-1.16.2 → agstack-1.17.1}/agstack.egg-info/top_level.txt +0 -0
  74. {agstack-1.16.2 → agstack-1.17.1}/setup.cfg +0 -0
  75. {agstack-1.16.2 → agstack-1.17.1}/tests/test_flow_io.py +0 -0
  76. {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.16.2
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.16.2
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.16.2"
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