kiba-core 0.5.3.dev50__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.
Files changed (103) hide show
  1. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/CHANGELOG.md +1 -0
  2. kiba_core-0.5.3.dev51/PKG-INFO +41 -0
  3. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/queues/aqs.py +2 -1
  4. kiba_core-0.5.3.dev51/core/queues/cosmos.py +190 -0
  5. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/queues/model.py +15 -3
  6. kiba_core-0.5.3.dev51/core/queues/sql.py +164 -0
  7. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/queues/sqs.py +2 -1
  8. kiba_core-0.5.3.dev51/kiba_core.egg-info/PKG-INFO +41 -0
  9. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/kiba_core.egg-info/SOURCES.txt +2 -0
  10. kiba_core-0.5.3.dev51/kiba_core.egg-info/requires.txt +44 -0
  11. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/pyproject.toml +22 -19
  12. kiba_core-0.5.3.dev51/uv.lock +3228 -0
  13. kiba_core-0.5.3.dev50/PKG-INFO +0 -39
  14. kiba_core-0.5.3.dev50/kiba_core.egg-info/PKG-INFO +0 -39
  15. kiba_core-0.5.3.dev50/kiba_core.egg-info/requires.txt +0 -41
  16. kiba_core-0.5.3.dev50/uv.lock +0 -3111
  17. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/.github/pull_request_template.md +0 -0
  18. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/.github/workflows/deploy.yml +0 -0
  19. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/.github/workflows/pull-request.yml +0 -0
  20. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/.github/workflows/release.yml +0 -0
  21. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/.gitignore +0 -0
  22. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/Dockerfile +0 -0
  23. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/README.md +0 -0
  24. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/__init__.py +0 -0
  25. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/api/__init__.py +0 -0
  26. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/api/api_request.py +0 -0
  27. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/api/api_response.py +0 -0
  28. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/api/authorizer.py +0 -0
  29. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/api/default_routes.py +0 -0
  30. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/api/health.py +0 -0
  31. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/api/json_route.py +0 -0
  32. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/api/middleware/__init__.py +0 -0
  33. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/api/middleware/database_connection_middleware.py +0 -0
  34. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/api/middleware/exception_handling_middleware.py +0 -0
  35. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/api/middleware/logging_middleware.py +0 -0
  36. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/api/middleware/server_headers_middleware.py +0 -0
  37. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/api/streaming_json_route.py +0 -0
  38. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/aws_requester.py +0 -0
  39. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/caching/__init__.py +0 -0
  40. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/caching/cache.py +0 -0
  41. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/caching/dict_cache.py +0 -0
  42. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/caching/file_cache.py +0 -0
  43. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/exceptions.py +0 -0
  44. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/http/__init__.py +0 -0
  45. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/http/basic_authentication.py +0 -0
  46. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/http/jwt.py +0 -0
  47. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/http/rest_method.py +0 -0
  48. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/logging.py +0 -0
  49. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/notifications/__init__.py +0 -0
  50. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/notifications/discord_client.py +0 -0
  51. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/notifications/notification_client.py +0 -0
  52. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/notifications/slack_client.py +0 -0
  53. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/py.typed +0 -0
  54. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/queues/__init__.py +0 -0
  55. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/queues/message_queue.py +0 -0
  56. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/queues/message_queue_processor.py +0 -0
  57. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/requester/__init__.py +0 -0
  58. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/requester/requester.py +0 -0
  59. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/s3_manager.py +0 -0
  60. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/service_client.py +0 -0
  61. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/store/__init__.py +0 -0
  62. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/store/database.py +0 -0
  63. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/store/retriever.py +0 -0
  64. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/store/saver.py +0 -0
  65. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/util/__init__.py +0 -0
  66. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/util/async_util.py +0 -0
  67. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/util/chain_util.py +0 -0
  68. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/util/date_util.py +0 -0
  69. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/util/dict_util.py +0 -0
  70. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/util/file_util.py +0 -0
  71. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/util/hashing_util.py +0 -0
  72. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/util/http_util.py +0 -0
  73. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/util/json_util.py +0 -0
  74. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/util/list_util.py +0 -0
  75. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/util/string_util.py +0 -0
  76. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/util/typing_util.py +0 -0
  77. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/util/url_util.py +0 -0
  78. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/util/value_holder.py +0 -0
  79. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/web3/__init__.py +0 -0
  80. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/web3/eth_client.py +0 -0
  81. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/core/web3/multicall3.py +0 -0
  82. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/kiba_core.egg-info/dependency_links.txt +0 -0
  83. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/kiba_core.egg-info/top_level.txt +0 -0
  84. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/makefile +0 -0
  85. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/setup.cfg +0 -0
  86. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/tests/__init__.py +0 -0
  87. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/tests/api/test_authorizer.py +0 -0
  88. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/tests/api/test_json_route.py +0 -0
  89. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/tests/api/test_streaming_json_route.py +0 -0
  90. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/tests/caching/test_dict_cache.py +0 -0
  91. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/tests/caching/test_file_cache.py +0 -0
  92. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/tests/util/__init__.py +0 -0
  93. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/tests/util/test_async_util.py +0 -0
  94. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/tests/util/test_chain_util.py +0 -0
  95. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/tests/util/test_date_util.py +0 -0
  96. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/tests/util/test_dict_util.py +0 -0
  97. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/tests/util/test_file_util.py +0 -0
  98. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/tests/util/test_json_util.py +0 -0
  99. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/tests/util/test_list_util.py +0 -0
  100. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/tests/util/test_string_util.py +0 -0
  101. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/tests/util/test_url_util.py +0 -0
  102. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev51}/tests/web3/__init__.py +0 -0
  103. {kiba_core-0.5.3.dev50 → 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): # type: ignore[explicit-any]
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): # type: ignore[explicit-any]
15
+ class Message(BaseModel):
15
16
  command: str
16
- content: dict[str, Any] # type: ignore[explicit-any]
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): # type: ignore[explicit-any]
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
 
@@ -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
@@ -1,54 +1,57 @@
1
1
  [project]
2
2
  name = "kiba-core"
3
- version = "0.5.3.dev50"
3
+ version = "0.5.3.dev51"
4
4
  description = "Kiba Labs' python utilities"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12, <4"
7
7
  dependencies = [
8
- "aiofiles>=24.1.0",
9
- "pydantic>=2.11.7",
10
- "orjson>=3.11.1",
8
+ "aiofiles>=25.1.0",
9
+ "pydantic>=2.13.4",
10
+ "orjson>=3.11.9",
11
11
  ]
12
12
 
13
13
  [project.optional-dependencies]
14
14
  api = [
15
- "fastapi>=0.116.1",
16
- "uvicorn[standard]>=0.35.0",
15
+ "fastapi>=0.139.1",
16
+ "uvicorn[standard]>=0.51.0",
17
17
  ]
18
18
  core-api = [
19
- "starlette>=0.47.2",
20
- "uvicorn[standard]>=0.35.0",
19
+ "starlette>=1.3.1",
20
+ "uvicorn[standard]>=0.51.0",
21
21
  ]
22
22
  storage = [
23
- "aiobotocore>=2.23.2",
23
+ "aiobotocore>=3.7.0",
24
24
  ]
25
25
  # TODO(krishan711): remove this in next major update
26
26
  queues = [
27
- "aiobotocore>=2.23.2",
27
+ "aiobotocore>=3.7.0",
28
28
  ]
29
29
  queue-sqs = [
30
- "aiobotocore>=2.23.2",
30
+ "aiobotocore>=3.7.0",
31
31
  ]
32
32
  queue-aqs = [
33
- "azure-storage-queue[aio]>=12.13.0",
33
+ "azure-storage-queue[aio]>=12.17.0",
34
+ ]
35
+ queue-cosmos = [
36
+ "azure-cosmos>=4.16.2",
34
37
  ]
35
38
  database-psql = [
36
- "sqlalchemy[asyncio]>=2.0.39",
37
- "asyncpg>=0.30.0",
39
+ "sqlalchemy[asyncio]>=2.0.51",
40
+ "asyncpg>=0.31.0",
38
41
  ]
39
42
  database-sqlite = [
40
- "sqlalchemy[asyncio]>=2.0.39",
41
- "aiosqlite>=0.21.0",
43
+ "sqlalchemy[asyncio]>=2.0.51",
44
+ "aiosqlite>=0.22.1",
42
45
  ]
43
46
  requester = [
44
47
  "httpx>=0.28.1",
45
48
  ]
46
49
  web3 = [
47
- "web3>=7.12.1",
50
+ "web3>=7.16.0",
48
51
  ]
49
52
  types = [
50
- "types-aiobotocore[essential]>=2.23.2",
51
- "types-aiofiles>=24.1.0.20250708",
53
+ "types-aiobotocore[essential]>=3.7.0",
54
+ "types-aiofiles>=25.1.0.20260518",
52
55
  ]
53
56
 
54
57
  [dependency-groups]