kiba-core 0.5.3.dev49__tar.gz → 0.5.3.dev51__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.
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/CHANGELOG.md +1 -0
- kiba_core-0.5.3.dev51/PKG-INFO +41 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/queues/aqs.py +2 -1
- kiba_core-0.5.3.dev51/core/queues/cosmos.py +190 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/queues/model.py +15 -3
- kiba_core-0.5.3.dev51/core/queues/sql.py +164 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/queues/sqs.py +2 -1
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/util/date_util.py +5 -0
- kiba_core-0.5.3.dev51/kiba_core.egg-info/PKG-INFO +41 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/kiba_core.egg-info/SOURCES.txt +2 -0
- kiba_core-0.5.3.dev51/kiba_core.egg-info/requires.txt +44 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/pyproject.toml +22 -19
- kiba_core-0.5.3.dev51/uv.lock +3228 -0
- kiba_core-0.5.3.dev49/PKG-INFO +0 -39
- kiba_core-0.5.3.dev49/kiba_core.egg-info/PKG-INFO +0 -39
- kiba_core-0.5.3.dev49/kiba_core.egg-info/requires.txt +0 -41
- kiba_core-0.5.3.dev49/uv.lock +0 -3068
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/.github/pull_request_template.md +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/.github/workflows/deploy.yml +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/.github/workflows/pull-request.yml +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/.github/workflows/release.yml +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/.gitignore +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/Dockerfile +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/README.md +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/__init__.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/api/__init__.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/api/api_request.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/api/api_response.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/api/authorizer.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/api/default_routes.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/api/health.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/api/json_route.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/api/middleware/__init__.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/api/middleware/database_connection_middleware.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/api/middleware/exception_handling_middleware.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/api/middleware/logging_middleware.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/api/middleware/server_headers_middleware.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/api/streaming_json_route.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/aws_requester.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/caching/__init__.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/caching/cache.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/caching/dict_cache.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/caching/file_cache.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/exceptions.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/http/__init__.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/http/basic_authentication.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/http/jwt.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/http/rest_method.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/logging.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/notifications/__init__.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/notifications/discord_client.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/notifications/notification_client.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/notifications/slack_client.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/py.typed +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/queues/__init__.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/queues/message_queue.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/queues/message_queue_processor.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/requester/__init__.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/requester/requester.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/s3_manager.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/service_client.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/store/__init__.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/store/database.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/store/retriever.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/store/saver.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/util/__init__.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/util/async_util.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/util/chain_util.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/util/dict_util.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/util/file_util.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/util/hashing_util.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/util/http_util.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/util/json_util.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/util/list_util.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/util/string_util.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/util/typing_util.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/util/url_util.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/util/value_holder.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/web3/__init__.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/web3/eth_client.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/core/web3/multicall3.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/kiba_core.egg-info/dependency_links.txt +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/kiba_core.egg-info/top_level.txt +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/makefile +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/setup.cfg +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/tests/__init__.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/tests/api/test_authorizer.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/tests/api/test_json_route.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/tests/api/test_streaming_json_route.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/tests/caching/test_dict_cache.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/tests/caching/test_file_cache.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/tests/util/__init__.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/tests/util/test_async_util.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/tests/util/test_chain_util.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/tests/util/test_date_util.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/tests/util/test_dict_util.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/tests/util/test_file_util.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/tests/util/test_json_util.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/tests/util/test_list_util.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/tests/util/test_string_util.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/tests/util/test_url_util.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/tests/web3/__init__.py +0 -0
- {kiba_core-0.5.3.dev49 → kiba_core-0.5.3.dev51}/tests/web3/test_rest_eth_client.py +0 -0
|
@@ -32,6 +32,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
|
|
|
32
32
|
- [MINOR] Added `call` and `multicall` to `EthClientInterface`
|
|
33
33
|
- [MINOR] Truncate discord notification messages to fit api
|
|
34
34
|
- [MINOR] Added `blockNumber` to `EthClient.multicall` function
|
|
35
|
+
- [MINOR] Added `SqlMessageQueue` and `CosmosMessageQueue` to support database backed queues
|
|
35
36
|
|
|
36
37
|
### Changed
|
|
37
38
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kiba-core
|
|
3
|
+
Version: 0.5.3.dev51
|
|
4
|
+
Summary: Kiba Labs' python utilities
|
|
5
|
+
Project-URL: repository, https://github.com/kibalabs/core-py
|
|
6
|
+
Requires-Python: <4,>=3.12
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: aiofiles>=25.1.0
|
|
9
|
+
Requires-Dist: pydantic>=2.13.4
|
|
10
|
+
Requires-Dist: orjson>=3.11.9
|
|
11
|
+
Provides-Extra: api
|
|
12
|
+
Requires-Dist: fastapi>=0.139.1; extra == "api"
|
|
13
|
+
Requires-Dist: uvicorn[standard]>=0.51.0; extra == "api"
|
|
14
|
+
Provides-Extra: core-api
|
|
15
|
+
Requires-Dist: starlette>=1.3.1; extra == "core-api"
|
|
16
|
+
Requires-Dist: uvicorn[standard]>=0.51.0; extra == "core-api"
|
|
17
|
+
Provides-Extra: storage
|
|
18
|
+
Requires-Dist: aiobotocore>=3.7.0; extra == "storage"
|
|
19
|
+
Provides-Extra: queues
|
|
20
|
+
Requires-Dist: aiobotocore>=3.7.0; extra == "queues"
|
|
21
|
+
Provides-Extra: queue-sqs
|
|
22
|
+
Requires-Dist: aiobotocore>=3.7.0; extra == "queue-sqs"
|
|
23
|
+
Provides-Extra: queue-aqs
|
|
24
|
+
Requires-Dist: azure-storage-queue[aio]>=12.17.0; extra == "queue-aqs"
|
|
25
|
+
Provides-Extra: queue-cosmos
|
|
26
|
+
Requires-Dist: azure-cosmos>=4.16.2; extra == "queue-cosmos"
|
|
27
|
+
Provides-Extra: database-psql
|
|
28
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0.51; extra == "database-psql"
|
|
29
|
+
Requires-Dist: asyncpg>=0.31.0; extra == "database-psql"
|
|
30
|
+
Provides-Extra: database-sqlite
|
|
31
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0.51; extra == "database-sqlite"
|
|
32
|
+
Requires-Dist: aiosqlite>=0.22.1; extra == "database-sqlite"
|
|
33
|
+
Provides-Extra: requester
|
|
34
|
+
Requires-Dist: httpx>=0.28.1; extra == "requester"
|
|
35
|
+
Provides-Extra: web3
|
|
36
|
+
Requires-Dist: web3>=7.16.0; extra == "web3"
|
|
37
|
+
Provides-Extra: types
|
|
38
|
+
Requires-Dist: types-aiobotocore[essential]>=3.7.0; extra == "types"
|
|
39
|
+
Requires-Dist: types-aiofiles>=25.1.0.20260518; extra == "types"
|
|
40
|
+
|
|
41
|
+
# Core-Py
|
|
@@ -12,7 +12,7 @@ from core.queues.model import Message
|
|
|
12
12
|
from core.util import list_util
|
|
13
13
|
|
|
14
14
|
|
|
15
|
-
class AqsMessage(Message):
|
|
15
|
+
class AqsMessage(Message):
|
|
16
16
|
aqsId: str
|
|
17
17
|
popReceipt: str | None
|
|
18
18
|
|
|
@@ -25,6 +25,7 @@ class AqsMessage(Message): # type: ignore[explicit-any]
|
|
|
25
25
|
requestId=message.requestId,
|
|
26
26
|
postCount=message.postCount,
|
|
27
27
|
postDate=message.postDate,
|
|
28
|
+
deduplicationId=message.deduplicationId,
|
|
28
29
|
aqsId=aqsMessage.id,
|
|
29
30
|
popReceipt=aqsMessage.pop_receipt,
|
|
30
31
|
)
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from azure.core import MatchConditions
|
|
11
|
+
from azure.cosmos import exceptions as cosmos_exceptions
|
|
12
|
+
from azure.cosmos.aio import ContainerProxy
|
|
13
|
+
|
|
14
|
+
from core.queues.message_queue import MessageQueue
|
|
15
|
+
from core.queues.model import Message
|
|
16
|
+
from core.util.typing_util import JsonObject
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CosmosMessage(Message):
|
|
20
|
+
id: str
|
|
21
|
+
leaseId: str
|
|
22
|
+
etag: str
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def from_item(cls, item: JsonObject) -> CosmosMessage:
|
|
26
|
+
message = Message.model_validate(
|
|
27
|
+
{
|
|
28
|
+
'command': item['command'],
|
|
29
|
+
'content': item['content'],
|
|
30
|
+
'requestId': item.get('requestId'),
|
|
31
|
+
'postCount': item.get('postCount'),
|
|
32
|
+
'postDate': item.get('postDate'),
|
|
33
|
+
'deduplicationId': item.get('deduplicationId'),
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
return cls(
|
|
37
|
+
command=message.command,
|
|
38
|
+
content=message.content,
|
|
39
|
+
requestId=message.requestId,
|
|
40
|
+
postCount=message.postCount,
|
|
41
|
+
postDate=message.postDate,
|
|
42
|
+
deduplicationId=message.deduplicationId,
|
|
43
|
+
id=str(item['id']),
|
|
44
|
+
leaseId=str(item['leaseId']),
|
|
45
|
+
etag=str(item['_etag']),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class CosmosMessageQueue(MessageQueue[CosmosMessage]):
|
|
50
|
+
def __init__(self, container: ContainerProxy, queueName: str, pollIntervalSeconds: float = 1.0) -> None:
|
|
51
|
+
self.container = container
|
|
52
|
+
self.queueName = queueName
|
|
53
|
+
self.pollIntervalSeconds = pollIntervalSeconds
|
|
54
|
+
|
|
55
|
+
async def connect(self) -> None:
|
|
56
|
+
# The caller supplies a shared ContainerProxy and owns CosmosClient lifecycle.
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
async def disconnect(self) -> None:
|
|
60
|
+
# The caller supplies a shared ContainerProxy and owns CosmosClient lifecycle.
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def _item_body(message: Message, queueName: str, itemId: str, visibleDate: float, createdDate: float) -> dict[str, Any]: # type: ignore[explicit-any]
|
|
65
|
+
return {
|
|
66
|
+
'id': itemId,
|
|
67
|
+
'itemType': 'message',
|
|
68
|
+
'queueName': queueName,
|
|
69
|
+
'command': message.command,
|
|
70
|
+
'content': message.content,
|
|
71
|
+
'requestId': message.requestId,
|
|
72
|
+
'postCount': message.postCount,
|
|
73
|
+
'postDate': message.postDate.isoformat() if message.postDate else None,
|
|
74
|
+
'deduplicationId': message.deduplicationId,
|
|
75
|
+
'leaseId': None,
|
|
76
|
+
'visibleDate': visibleDate,
|
|
77
|
+
'createdDate': createdDate,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async def send_message(self, message: Message, delaySeconds: int = 0) -> None:
|
|
81
|
+
await self.send_messages(messages=[message], delaySeconds=delaySeconds)
|
|
82
|
+
|
|
83
|
+
async def send_messages(self, messages: Sequence[Message], delaySeconds: int = 0) -> None:
|
|
84
|
+
if not messages:
|
|
85
|
+
return
|
|
86
|
+
now = time.time()
|
|
87
|
+
visibleDate = now + delaySeconds
|
|
88
|
+
for message in messages:
|
|
89
|
+
message.prepare_for_send()
|
|
90
|
+
itemId = uuid.uuid4().hex
|
|
91
|
+
item = self._item_body(message=message, queueName=self.queueName, itemId=itemId, visibleDate=visibleDate, createdDate=now)
|
|
92
|
+
if message.deduplicationId is None:
|
|
93
|
+
# The SDK derives the create partition key from the body's queueName field.
|
|
94
|
+
await self.container.create_item(body=item)
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
deduplicationKey = f'{self.queueName}:{message.deduplicationId}'
|
|
98
|
+
deduplicationItemId = f'dedup-{hashlib.sha256(deduplicationKey.encode()).hexdigest()}'
|
|
99
|
+
deduplicationItem = {
|
|
100
|
+
'id': deduplicationItemId,
|
|
101
|
+
'itemType': 'deduplication',
|
|
102
|
+
'queueName': self.queueName,
|
|
103
|
+
'deduplicationId': message.deduplicationId,
|
|
104
|
+
'messageId': itemId,
|
|
105
|
+
}
|
|
106
|
+
try:
|
|
107
|
+
await self.container.execute_item_batch(
|
|
108
|
+
batch_operations=[
|
|
109
|
+
('create', (deduplicationItem,)),
|
|
110
|
+
('create', (item,)),
|
|
111
|
+
],
|
|
112
|
+
partition_key=self.queueName,
|
|
113
|
+
)
|
|
114
|
+
except cosmos_exceptions.CosmosBatchOperationError as exception:
|
|
115
|
+
if exception.error_index != 0 or exception.status_code != 409: # noqa: PLR2004
|
|
116
|
+
raise
|
|
117
|
+
|
|
118
|
+
async def get_message(self, expectedProcessingSeconds: int = 300, longPollSeconds: int = 0) -> CosmosMessage | None:
|
|
119
|
+
messages = await self.get_messages(limit=1, expectedProcessingSeconds=expectedProcessingSeconds, longPollSeconds=longPollSeconds)
|
|
120
|
+
return messages[0] if messages else None
|
|
121
|
+
|
|
122
|
+
async def get_messages(self, limit: int = 1, expectedProcessingSeconds: int = 300, longPollSeconds: int = 0) -> list[CosmosMessage]:
|
|
123
|
+
if limit <= 0:
|
|
124
|
+
return []
|
|
125
|
+
deadline = time.monotonic() + longPollSeconds
|
|
126
|
+
while True:
|
|
127
|
+
messages = await self._claim_messages(limit=limit, expectedProcessingSeconds=expectedProcessingSeconds)
|
|
128
|
+
if messages or time.monotonic() >= deadline:
|
|
129
|
+
return messages
|
|
130
|
+
await asyncio.sleep(min(self.pollIntervalSeconds, max(deadline - time.monotonic(), 0)))
|
|
131
|
+
|
|
132
|
+
async def _claim_messages(self, limit: int, expectedProcessingSeconds: int) -> list[CosmosMessage]:
|
|
133
|
+
now = time.time()
|
|
134
|
+
query = 'SELECT * FROM c WHERE c.itemType = @itemType AND c.queueName = @queueName AND c.visibleDate <= @now ORDER BY c.createdDate'
|
|
135
|
+
candidates = self.container.query_items(
|
|
136
|
+
query=query,
|
|
137
|
+
parameters=[
|
|
138
|
+
{'name': '@itemType', 'value': 'message'},
|
|
139
|
+
{'name': '@queueName', 'value': self.queueName},
|
|
140
|
+
{'name': '@now', 'value': now},
|
|
141
|
+
],
|
|
142
|
+
partition_key=self.queueName,
|
|
143
|
+
# Read ahead so ETag conflicts caused by concurrent workers do not unnecessarily
|
|
144
|
+
# under-fill this batch; candidates that another worker claims are skipped.
|
|
145
|
+
max_item_count=max(limit * 2, 10),
|
|
146
|
+
)
|
|
147
|
+
messages: list[CosmosMessage] = []
|
|
148
|
+
async for candidate in candidates:
|
|
149
|
+
item = {key: value for key, value in candidate.items() if not key.startswith('_')}
|
|
150
|
+
leaseId = uuid.uuid4().hex
|
|
151
|
+
item['leaseId'] = leaseId
|
|
152
|
+
item['visibleDate'] = now + expectedProcessingSeconds
|
|
153
|
+
try:
|
|
154
|
+
claimedItem = await self.container.replace_item(
|
|
155
|
+
item=candidate['id'],
|
|
156
|
+
body=item,
|
|
157
|
+
etag=candidate['_etag'],
|
|
158
|
+
match_condition=MatchConditions.IfNotModified,
|
|
159
|
+
# The async SDK derives this from the body, but explicitly pass it in
|
|
160
|
+
# request options so the replace is targeted to this logical partition.
|
|
161
|
+
request_options={'partitionKey': self.queueName},
|
|
162
|
+
)
|
|
163
|
+
except cosmos_exceptions.CosmosHttpResponseError as exception:
|
|
164
|
+
if exception.status_code == 412: # noqa: PLR2004
|
|
165
|
+
continue
|
|
166
|
+
raise
|
|
167
|
+
messages.append(CosmosMessage.from_item(item=claimedItem))
|
|
168
|
+
if len(messages) >= limit:
|
|
169
|
+
break
|
|
170
|
+
return messages
|
|
171
|
+
|
|
172
|
+
async def delete_message(self, message: CosmosMessage) -> None:
|
|
173
|
+
if message.deduplicationId is None:
|
|
174
|
+
await self.container.delete_item(
|
|
175
|
+
item=message.id,
|
|
176
|
+
partition_key=self.queueName,
|
|
177
|
+
etag=message.etag,
|
|
178
|
+
match_condition=MatchConditions.IfNotModified,
|
|
179
|
+
)
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
deduplicationKey = f'{self.queueName}:{message.deduplicationId}'
|
|
183
|
+
deduplicationItemId = f'dedup-{hashlib.sha256(deduplicationKey.encode()).hexdigest()}'
|
|
184
|
+
await self.container.execute_item_batch(
|
|
185
|
+
batch_operations=[
|
|
186
|
+
('delete', (message.id,), {'if_match_etag': message.etag}),
|
|
187
|
+
('delete', (deduplicationItemId,)),
|
|
188
|
+
],
|
|
189
|
+
partition_key=self.queueName,
|
|
190
|
+
)
|
|
@@ -1,22 +1,24 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import datetime
|
|
4
|
+
import hashlib
|
|
4
5
|
import typing
|
|
5
6
|
import uuid
|
|
6
|
-
from typing import Any
|
|
7
7
|
|
|
8
8
|
from pydantic import BaseModel
|
|
9
9
|
from pydantic.fields import ModelPrivateAttr
|
|
10
10
|
|
|
11
11
|
from core.util import date_util
|
|
12
|
+
from core.util.typing_util import JsonObject
|
|
12
13
|
|
|
13
14
|
|
|
14
|
-
class Message(BaseModel):
|
|
15
|
+
class Message(BaseModel):
|
|
15
16
|
command: str
|
|
16
|
-
content:
|
|
17
|
+
content: JsonObject
|
|
17
18
|
requestId: str | None
|
|
18
19
|
postCount: int | None
|
|
19
20
|
postDate: datetime.datetime | None
|
|
21
|
+
deduplicationId: str | None = None
|
|
20
22
|
|
|
21
23
|
def prepare_for_send(self) -> None:
|
|
22
24
|
self.requestId = self.requestId or str(uuid.uuid4()).replace('-', '')
|
|
@@ -31,6 +33,10 @@ class MessageContent(BaseModel):
|
|
|
31
33
|
def get_command(cls) -> str:
|
|
32
34
|
return typing.cast(str, typing.cast(ModelPrivateAttr, cls._COMMAND).get_default())
|
|
33
35
|
|
|
36
|
+
def get_deduplication_id(self) -> str | None:
|
|
37
|
+
payload = f'{self.get_command()}:{self.model_dump_json()}'
|
|
38
|
+
return hashlib.sha256(payload.encode()).hexdigest()
|
|
39
|
+
|
|
34
40
|
def to_message(self) -> Message:
|
|
35
41
|
return Message(
|
|
36
42
|
command=self.get_command(),
|
|
@@ -38,4 +44,10 @@ class MessageContent(BaseModel):
|
|
|
38
44
|
requestId=None,
|
|
39
45
|
postCount=None,
|
|
40
46
|
postDate=None,
|
|
47
|
+
deduplicationId=self.get_deduplication_id(),
|
|
41
48
|
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class NonDeduplicatedMessageContent(MessageContent):
|
|
52
|
+
def get_deduplication_id(self) -> str | None:
|
|
53
|
+
return None
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
import uuid
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import sqlalchemy
|
|
11
|
+
from sqlalchemy.dialects import postgresql as sqlalchemy_psql
|
|
12
|
+
|
|
13
|
+
from core.queues.message_queue import MessageQueue
|
|
14
|
+
from core.queues.model import Message
|
|
15
|
+
from core.store.database import Database
|
|
16
|
+
from core.util import date_util
|
|
17
|
+
|
|
18
|
+
# NOTE: this table is owned here (not in the consuming app's schema.py) so it can ship with
|
|
19
|
+
# core-py. To include it in an app's own alembic-tracked metadata (so autogenerate creates the
|
|
20
|
+
# table), call `QueueMessagesTable.to_metadata(appMetadata)` from the app's schema module.
|
|
21
|
+
QueueMessagesMetadata = sqlalchemy.MetaData()
|
|
22
|
+
|
|
23
|
+
QueueMessagesTable = sqlalchemy.Table(
|
|
24
|
+
'tbl_queue_messages',
|
|
25
|
+
QueueMessagesMetadata,
|
|
26
|
+
sqlalchemy.Column(key='id', name='id', type_=sqlalchemy.Integer, autoincrement=True, primary_key=True, nullable=False),
|
|
27
|
+
sqlalchemy.Column(key='createdDate', name='created_date', type_=sqlalchemy.DateTime(timezone=True), nullable=False),
|
|
28
|
+
sqlalchemy.Column(key='queueName', name='queue_name', type_=sqlalchemy.Text, nullable=False),
|
|
29
|
+
sqlalchemy.Column(key='command', name='command', type_=sqlalchemy.Text, nullable=False),
|
|
30
|
+
sqlalchemy.Column(key='content', name='content', type_=sqlalchemy.JSON().with_variant(sqlalchemy_psql.JSONB(), 'postgresql'), nullable=False),
|
|
31
|
+
sqlalchemy.Column(key='requestId', name='request_id', type_=sqlalchemy.Text, nullable=True),
|
|
32
|
+
sqlalchemy.Column(key='postCount', name='post_count', type_=sqlalchemy.Integer, nullable=True),
|
|
33
|
+
sqlalchemy.Column(key='postDate', name='post_date', type_=sqlalchemy.DateTime(timezone=True), nullable=True),
|
|
34
|
+
sqlalchemy.Column(key='deduplicationId', name='deduplication_id', type_=sqlalchemy.Text, nullable=True),
|
|
35
|
+
sqlalchemy.Column(key='lockToken', name='lock_token', type_=sqlalchemy.Text, nullable=True),
|
|
36
|
+
sqlalchemy.Column(key='visibleDate', name='visible_date', type_=sqlalchemy.DateTime(timezone=True), nullable=False, index=True),
|
|
37
|
+
sqlalchemy.UniqueConstraint('queueName', 'deduplicationId', name='tbl_queue_messages_ux_queue_name_deduplication_id'),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SqlMessage(Message):
|
|
42
|
+
id: int
|
|
43
|
+
lockToken: str
|
|
44
|
+
|
|
45
|
+
@staticmethod
|
|
46
|
+
def _get_row_value(row: Mapping[str, Any], key: str, databaseKey: str) -> Any: # type: ignore[explicit-any]
|
|
47
|
+
if key in row:
|
|
48
|
+
return row[key]
|
|
49
|
+
return row[databaseKey]
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_row(cls, row: Mapping[str, Any]) -> SqlMessage: # type: ignore[explicit-any]
|
|
53
|
+
return cls(
|
|
54
|
+
id=cls._get_row_value(row, 'id', 'id'),
|
|
55
|
+
command=cls._get_row_value(row, 'command', 'command'),
|
|
56
|
+
content=cls._get_row_value(row, 'content', 'content'),
|
|
57
|
+
requestId=cls._get_row_value(row, 'requestId', 'request_id'),
|
|
58
|
+
postCount=cls._get_row_value(row, 'postCount', 'post_count'),
|
|
59
|
+
postDate=cls._get_row_value(row, 'postDate', 'post_date'),
|
|
60
|
+
deduplicationId=cls._get_row_value(row, 'deduplicationId', 'deduplication_id'),
|
|
61
|
+
lockToken=cls._get_row_value(row, 'lockToken', 'lock_token'),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class SqlMessageQueue(MessageQueue[SqlMessage]):
|
|
66
|
+
def __init__(self, database: Database, queueName: str, table: sqlalchemy.Table = QueueMessagesTable, pollIntervalSeconds: float = 1.0) -> None:
|
|
67
|
+
self.database = database
|
|
68
|
+
self.queueName = queueName
|
|
69
|
+
self.table = table
|
|
70
|
+
self.pollIntervalSeconds = pollIntervalSeconds
|
|
71
|
+
|
|
72
|
+
async def connect(self) -> None:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
async def disconnect(self) -> None:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
def _is_deduplication_conflict(self, exception: sqlalchemy.exc.IntegrityError) -> bool:
|
|
79
|
+
deduplicationConstraintName = next(
|
|
80
|
+
(constraint.name for constraint in self.table.constraints if isinstance(constraint, sqlalchemy.UniqueConstraint) and {column.key for column in constraint.columns} == {'queueName', 'deduplicationId'}),
|
|
81
|
+
None,
|
|
82
|
+
)
|
|
83
|
+
original = exception.orig
|
|
84
|
+
originalConstraintName = getattr(original, 'constraint_name', None)
|
|
85
|
+
if originalConstraintName is None:
|
|
86
|
+
originalConstraintName = getattr(getattr(original, 'diag', None), 'constraint_name', None)
|
|
87
|
+
if deduplicationConstraintName is not None:
|
|
88
|
+
constraintName = str(deduplicationConstraintName)
|
|
89
|
+
if originalConstraintName == constraintName:
|
|
90
|
+
return True
|
|
91
|
+
errorMessage = str(original)
|
|
92
|
+
if constraintName in errorMessage:
|
|
93
|
+
return True
|
|
94
|
+
errorMessage = str(original)
|
|
95
|
+
sqliteColumns = f'{self.table.name}.{self.table.c.queueName.name}, {self.table.name}.{self.table.c.deduplicationId.name}'
|
|
96
|
+
return 'unique constraint failed:' in errorMessage.lower() and sqliteColumns in errorMessage
|
|
97
|
+
|
|
98
|
+
async def send_message(self, message: Message, delaySeconds: int = 0) -> None:
|
|
99
|
+
await self.send_messages(messages=[message], delaySeconds=delaySeconds)
|
|
100
|
+
|
|
101
|
+
async def send_messages(self, messages: Sequence[Message], delaySeconds: int = 0) -> None:
|
|
102
|
+
if not messages:
|
|
103
|
+
return
|
|
104
|
+
now = date_util.datetime_from_now()
|
|
105
|
+
visibleDate = date_util.datetime_from_now(seconds=delaySeconds)
|
|
106
|
+
async with self.database.create_transaction() as connection:
|
|
107
|
+
for message in messages:
|
|
108
|
+
message.prepare_for_send()
|
|
109
|
+
insertQuery = self.table.insert().values(
|
|
110
|
+
queueName=self.queueName,
|
|
111
|
+
command=message.command,
|
|
112
|
+
content=message.content,
|
|
113
|
+
requestId=message.requestId,
|
|
114
|
+
postCount=message.postCount,
|
|
115
|
+
postDate=message.postDate,
|
|
116
|
+
deduplicationId=message.deduplicationId,
|
|
117
|
+
lockToken=None,
|
|
118
|
+
visibleDate=visibleDate,
|
|
119
|
+
createdDate=now,
|
|
120
|
+
)
|
|
121
|
+
if message.deduplicationId is not None:
|
|
122
|
+
# A message with the same (queueName, deduplicationId) may already be queued;
|
|
123
|
+
# the unique constraint on those columns rejects it. Use a SAVEPOINT so that
|
|
124
|
+
# failure only rolls back this one insert, not the whole batch's transaction.
|
|
125
|
+
try:
|
|
126
|
+
async with connection.begin_nested():
|
|
127
|
+
await self.database.execute(query=insertQuery, connection=connection) # type: ignore[arg-type]
|
|
128
|
+
except sqlalchemy.exc.IntegrityError as exception:
|
|
129
|
+
if not self._is_deduplication_conflict(exception):
|
|
130
|
+
raise
|
|
131
|
+
else:
|
|
132
|
+
await self.database.execute(query=insertQuery, connection=connection) # type: ignore[arg-type]
|
|
133
|
+
|
|
134
|
+
async def get_message(self, expectedProcessingSeconds: int = 300, longPollSeconds: int = 0) -> SqlMessage | None:
|
|
135
|
+
messages = await self.get_messages(limit=1, expectedProcessingSeconds=expectedProcessingSeconds, longPollSeconds=longPollSeconds)
|
|
136
|
+
return messages[0] if messages else None
|
|
137
|
+
|
|
138
|
+
async def get_messages(self, limit: int = 1, expectedProcessingSeconds: int = 300, longPollSeconds: int = 0) -> list[SqlMessage]:
|
|
139
|
+
deadline = time.monotonic() + longPollSeconds
|
|
140
|
+
while True:
|
|
141
|
+
messages = await self._claim_messages(limit=limit, expectedProcessingSeconds=expectedProcessingSeconds)
|
|
142
|
+
if messages or time.monotonic() >= deadline:
|
|
143
|
+
return messages
|
|
144
|
+
await asyncio.sleep(min(self.pollIntervalSeconds, max(deadline - time.monotonic(), 0)))
|
|
145
|
+
|
|
146
|
+
async def _claim_messages(self, limit: int, expectedProcessingSeconds: int) -> list[SqlMessage]:
|
|
147
|
+
now = date_util.datetime_from_now()
|
|
148
|
+
newVisibleDate = date_util.datetime_from_now(seconds=expectedProcessingSeconds)
|
|
149
|
+
async with self.database.create_transaction() as connection:
|
|
150
|
+
selectQuery = sqlalchemy.select(self.table).where(self.table.c.queueName == self.queueName).where(self.table.c.visibleDate <= now).order_by(self.table.c.id).limit(limit).with_for_update(skip_locked=True)
|
|
151
|
+
result = await self.database.execute(query=selectQuery, connection=connection)
|
|
152
|
+
rows = result.mappings().all()
|
|
153
|
+
messages: list[SqlMessage] = []
|
|
154
|
+
for row in rows:
|
|
155
|
+
lockToken = str(uuid.uuid4())
|
|
156
|
+
updateQuery = sqlalchemy.update(self.table).where(self.table.c.id == row['id']).values(lockToken=lockToken, visibleDate=newVisibleDate)
|
|
157
|
+
await self.database.execute(query=updateQuery, connection=connection) # type: ignore[arg-type]
|
|
158
|
+
messages.append(SqlMessage.from_row(row={**row, 'lockToken': lockToken}))
|
|
159
|
+
return messages
|
|
160
|
+
|
|
161
|
+
async def delete_message(self, message: SqlMessage) -> None:
|
|
162
|
+
async with self.database.create_transaction() as connection:
|
|
163
|
+
deleteQuery = sqlalchemy.delete(self.table).where(self.table.c.id == message.id).where(self.table.c.lockToken == message.lockToken)
|
|
164
|
+
await self.database.execute(query=deleteQuery, connection=connection) # type: ignore[arg-type]
|
|
@@ -22,7 +22,7 @@ else:
|
|
|
22
22
|
RawSqsMessageTypeDef = Any
|
|
23
23
|
|
|
24
24
|
|
|
25
|
-
class SqsMessage(Message):
|
|
25
|
+
class SqsMessage(Message):
|
|
26
26
|
receiptHandle: str
|
|
27
27
|
|
|
28
28
|
@classmethod
|
|
@@ -34,6 +34,7 @@ class SqsMessage(Message): # type: ignore[explicit-any]
|
|
|
34
34
|
requestId=message.requestId,
|
|
35
35
|
postCount=message.postCount,
|
|
36
36
|
postDate=message.postDate,
|
|
37
|
+
deduplicationId=message.deduplicationId,
|
|
37
38
|
receiptHandle=sqsMessage['ReceiptHandle'],
|
|
38
39
|
)
|
|
39
40
|
|
|
@@ -29,6 +29,11 @@ def datetime_from_now(days: int = 0, seconds: float = 0, milliseconds: int = 0,
|
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
def datetime_from_string(dateString: str, dateFormat: str = JSON_DATE_FORMAT) -> datetime.datetime:
|
|
32
|
+
try:
|
|
33
|
+
dt = datetime.datetime.fromisoformat(dateString)
|
|
34
|
+
return dt.astimezone(datetime.UTC) if dt.tzinfo is not None else dt.replace(tzinfo=datetime.UTC)
|
|
35
|
+
except (TypeError, ValueError):
|
|
36
|
+
pass
|
|
32
37
|
try:
|
|
33
38
|
dt = datetime.datetime.strptime(dateString, dateFormat).replace(tzinfo=datetime.UTC)
|
|
34
39
|
except (TypeError, ValueError) as exception:
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kiba-core
|
|
3
|
+
Version: 0.5.3.dev51
|
|
4
|
+
Summary: Kiba Labs' python utilities
|
|
5
|
+
Project-URL: repository, https://github.com/kibalabs/core-py
|
|
6
|
+
Requires-Python: <4,>=3.12
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: aiofiles>=25.1.0
|
|
9
|
+
Requires-Dist: pydantic>=2.13.4
|
|
10
|
+
Requires-Dist: orjson>=3.11.9
|
|
11
|
+
Provides-Extra: api
|
|
12
|
+
Requires-Dist: fastapi>=0.139.1; extra == "api"
|
|
13
|
+
Requires-Dist: uvicorn[standard]>=0.51.0; extra == "api"
|
|
14
|
+
Provides-Extra: core-api
|
|
15
|
+
Requires-Dist: starlette>=1.3.1; extra == "core-api"
|
|
16
|
+
Requires-Dist: uvicorn[standard]>=0.51.0; extra == "core-api"
|
|
17
|
+
Provides-Extra: storage
|
|
18
|
+
Requires-Dist: aiobotocore>=3.7.0; extra == "storage"
|
|
19
|
+
Provides-Extra: queues
|
|
20
|
+
Requires-Dist: aiobotocore>=3.7.0; extra == "queues"
|
|
21
|
+
Provides-Extra: queue-sqs
|
|
22
|
+
Requires-Dist: aiobotocore>=3.7.0; extra == "queue-sqs"
|
|
23
|
+
Provides-Extra: queue-aqs
|
|
24
|
+
Requires-Dist: azure-storage-queue[aio]>=12.17.0; extra == "queue-aqs"
|
|
25
|
+
Provides-Extra: queue-cosmos
|
|
26
|
+
Requires-Dist: azure-cosmos>=4.16.2; extra == "queue-cosmos"
|
|
27
|
+
Provides-Extra: database-psql
|
|
28
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0.51; extra == "database-psql"
|
|
29
|
+
Requires-Dist: asyncpg>=0.31.0; extra == "database-psql"
|
|
30
|
+
Provides-Extra: database-sqlite
|
|
31
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0.51; extra == "database-sqlite"
|
|
32
|
+
Requires-Dist: aiosqlite>=0.22.1; extra == "database-sqlite"
|
|
33
|
+
Provides-Extra: requester
|
|
34
|
+
Requires-Dist: httpx>=0.28.1; extra == "requester"
|
|
35
|
+
Provides-Extra: web3
|
|
36
|
+
Requires-Dist: web3>=7.16.0; extra == "web3"
|
|
37
|
+
Provides-Extra: types
|
|
38
|
+
Requires-Dist: types-aiobotocore[essential]>=3.7.0; extra == "types"
|
|
39
|
+
Requires-Dist: types-aiofiles>=25.1.0.20260518; extra == "types"
|
|
40
|
+
|
|
41
|
+
# Core-Py
|
|
@@ -43,9 +43,11 @@ core/notifications/notification_client.py
|
|
|
43
43
|
core/notifications/slack_client.py
|
|
44
44
|
core/queues/__init__.py
|
|
45
45
|
core/queues/aqs.py
|
|
46
|
+
core/queues/cosmos.py
|
|
46
47
|
core/queues/message_queue.py
|
|
47
48
|
core/queues/message_queue_processor.py
|
|
48
49
|
core/queues/model.py
|
|
50
|
+
core/queues/sql.py
|
|
49
51
|
core/queues/sqs.py
|
|
50
52
|
core/requester/__init__.py
|
|
51
53
|
core/requester/requester.py
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
aiofiles>=25.1.0
|
|
2
|
+
pydantic>=2.13.4
|
|
3
|
+
orjson>=3.11.9
|
|
4
|
+
|
|
5
|
+
[api]
|
|
6
|
+
fastapi>=0.139.1
|
|
7
|
+
uvicorn[standard]>=0.51.0
|
|
8
|
+
|
|
9
|
+
[core-api]
|
|
10
|
+
starlette>=1.3.1
|
|
11
|
+
uvicorn[standard]>=0.51.0
|
|
12
|
+
|
|
13
|
+
[database-psql]
|
|
14
|
+
sqlalchemy[asyncio]>=2.0.51
|
|
15
|
+
asyncpg>=0.31.0
|
|
16
|
+
|
|
17
|
+
[database-sqlite]
|
|
18
|
+
sqlalchemy[asyncio]>=2.0.51
|
|
19
|
+
aiosqlite>=0.22.1
|
|
20
|
+
|
|
21
|
+
[queue-aqs]
|
|
22
|
+
azure-storage-queue[aio]>=12.17.0
|
|
23
|
+
|
|
24
|
+
[queue-cosmos]
|
|
25
|
+
azure-cosmos>=4.16.2
|
|
26
|
+
|
|
27
|
+
[queue-sqs]
|
|
28
|
+
aiobotocore>=3.7.0
|
|
29
|
+
|
|
30
|
+
[queues]
|
|
31
|
+
aiobotocore>=3.7.0
|
|
32
|
+
|
|
33
|
+
[requester]
|
|
34
|
+
httpx>=0.28.1
|
|
35
|
+
|
|
36
|
+
[storage]
|
|
37
|
+
aiobotocore>=3.7.0
|
|
38
|
+
|
|
39
|
+
[types]
|
|
40
|
+
types-aiobotocore[essential]>=3.7.0
|
|
41
|
+
types-aiofiles>=25.1.0.20260518
|
|
42
|
+
|
|
43
|
+
[web3]
|
|
44
|
+
web3>=7.16.0
|